Merge branch 'develop' into 'master'

Develop to master

See merge request framasoft/framadate/funky-framadate-front!47
This commit is contained in:
ty kayn 2020-11-24 10:51:10 +01:00
commit fd82a3b6b2
423 changed files with 82541 additions and 0 deletions

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

1
.eslintcache Normal file

File diff suppressed because one or more lines are too long

20
.eslintrc.js Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'prettier/@typescript-eslint',
],
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
rules: {
'@typescript-eslint/unbound-method': ['error', { ignoreStatic: true }],
},
};

54
.gitignore vendored Normal file
View File

@ -0,0 +1,54 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc/
documentation
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
.idea
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
# Editor-specific configuration
.vscode/
# linter
.eslintcache

65
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,65 @@
# image: weboaks/node-karma-protractor-chrome
image: node:latest
stages:
- build
# - pages
- test
# - e2e
cache:
paths:
- node_modules/
#pages:
# stage: pages
# script:
# - yarn install --pure-lockfile
# - yarn build:prod:gitlabpage
# - mv dist/framadate/ public/
# artifacts:
# paths:
# - public
# expire_in: '2 hours'
# only:
# - develop
#test:
# stage: test
# script:
# - npm i
# - pkill Xvfb
# - npm run test:ci
# artifacts:
# paths:
# - coverage/
# cache:
# policy: pull
build:
stage: build
script:
- yarn install --pure-lockfile
- npx ng build --prod
cache:
policy: pull
#e2e:
# stage: e2e
# script:
# - npm i
# - pkill Xvfb
# - npm run e2e
#pages:
# stage: .post
# dependencies:
# - test
# script:
# - mv coverage/ public/
# artifacts:
# paths:
# - public
# expire_in: 30 days
# only:
# - master

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
package-lock.json
yarn-lock.json
yarn.lock
node_modules
build
dist
res
coverage
.vscode/*

3
.prettierrc.yaml Normal file
View File

@ -0,0 +1,3 @@
printWidth: 120
singleQuote: true
tabWidth: 4

0
LICENSE.md Normal file
View File

92
README.md Normal file
View File

@ -0,0 +1,92 @@
# Framadate - funky version
FR: Un logiciel libre de sondage fait par les contributeurs de l'association Framasoft, avec une API backend.
EN: A libre polling software made by contributors around the French association Framasoft.
This version uses a brand new backend API.
## Pour débuter - getting started
[lire la doc pour débuter votre Funky Framadate](doc/GETTING_STARTED.md)
## Documentation
FR: Toute la documentation est disponible [dans le dossier "doc"](/doc), principalement en Français.
EN: All documentation is available in the "doc" folder, mainly in French because reasons.
* Meeting notes
* Getting Started (yarn start / npm start)
* How to contribute
* Architecture
* Translation i18n
* Accesibility
* Licence GNU affero V3
# Version funky framadate
* [Spécifications](docs/cadrage/specifications-fonctionnelles)
* maquettes par @maiwann : https://scene.zeplin.io/project/5d4d83d68866d6522ff2ff10
* vidéo de démo des maquettes par @maiwann : https://nuage.maiwann.net/s/JRRHTR9D2akMAa7
* discussions sur framateam canal général : https://framateam.org/ux-framatrucs/channaels/framadate
* discussions techniques côté développeurs : https://framateam.org/ux-framatrucs/channels/framadate-dev
* [notes de réunion](notes-de-reunion)
* [traductions](traductions)
# Documentations sur Angular
* `{- sur sass -}` (on va utiliser CSS, si angular permet d'avoir des variables CSS, @newick)
# Exemple de maquette de la nouvelle version
![funky_framadate_maquette](uploads/535da7c3a5ce5fae67b2b497bdc4631d/funky_framadate_maquette.png)
## LIBRARIES USED
| status | lib choice_label | usage |
| :-------------: | -------------------------------------------------------------- | --------------------------------------------------------- |
| | [axios](https://github.com/axios/axios) | http client |
| | [bulma](https://bulma.io/) | CSS framework |
| | [chart.js](https://www.chartjs.org/) | PrimeNG solution for graphs. (Chart.js installs MomentJS) |
| | [compodoc](https://compodoc.app/) | Generate technic documentation |
| | ESlint, Prettier, Lint-staged | Format & lint code |
| | [fork-awesome](https://forkaweso.me) | Icons collection |
| | [fullcalendar](https://fullcalendar.io/docs/initialize-es6) | PrimeNG solution to manage & display calendars |
| | [husky](https://www.npmjs.com/package/husky) | Hook actions on commit |
| | [jest](https://jestjs.io/) | test engine |
| | [json-server](https://www.npmjs.com/package/json-server) | local server for mocking data backend |
| removed | [locale-enum](https://www.npmjs.com/package/locale-enum) | enum of all locales |
| | [momentJS](https://momentjs.com/) | manipulate dates. (chartJSs dependency) |
| to be installed | [ng2-charts](https://valor-software.com/ng2-charts/) | Manipulate graphs along with chart.js |
| | [ngx-clipboard](https://www.npmjs.com/package/ngx-clipboard) | Handle clipboard |
| | [ngx-markdown](https://www.npmjs.com/package/ngx-markdown) | markdown parser |
| | [ngx-webstorage](https://www.npmjs.com/package/ngx-webstorage) | handle localStorage & webStorage |
| | [primeNG](https://www.primefaces.org/primeng/) | UI components collection |
| | [quill](https://www.npmjs.com/package/quill) | powerful rich text editor. WYSIWYG. |
| to be installed | [short-uuid](https://www.npmjs.com/package/short-uuid) | generate uuid |
| removed | [storybook](https://storybook.js.org/) | StyleGuide UI |
| | [ts-mockito](https://www.npmjs.com/package/ts-mockito) | Mocks for testing. |
| to be removed | [uuid](https://www.npmjs.com/package/uuid) | generate uuid |
---
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.2.1.
# Framadate
## Code scaffolding
Run `ng generate component component-choice_label` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
## Further help
Before using ng for the first time in this project, use `npm i` to install needed npm modules.
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

121
angular.json Normal file
View File

@ -0,0 +1,121 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"framadate": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/framadate",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": [
"node_modules/fork-awesome/css/fork-awesome.min.css",
"node_modules/primeng/resources/themes/nova-light/theme.css",
"node_modules/primeng/resources/primeng.min.css",
"node_modules/bulma-switch/dist/css/bulma-switch.min.css",
"src/styles.scss"
],
"scripts": [
"node_modules/chart.js/dist/Chart.min.js",
"node_modules/marked/lib/marked.js",
"node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-css.min.js"
]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"crossOrigin": "anonymous",
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "framadate:build",
"proxyConfig": "src/proxy.conf.json"
},
"configurations": {
"production": {
"browserTarget": "framadate:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "framadate:build"
}
},
"test": {
"builder": "@angular-builders/jest:run",
"options": {}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["tsconfig.app.json", "tsconfig.spec.json", "e2e/tsconfig.json"],
"exclude": ["**/node_modules/**"]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "framadate:serve"
},
"configurations": {
"production": {
"devServerTarget": "framadate:serve:production"
}
}
}
}
}
},
"defaultProject": "framadate",
"cli": {
"analytics": "0ba9c0a9-850f-4c5f-8124-cbe6f4c79ef1"
}
}

3
babel.config.json Normal file
View File

@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

12
browserslist Normal file
View File

@ -0,0 +1,12 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# You can see what browsers were selected by your queries by running:
# npx browserslist
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.

51
doc/CONTRIBUTE.md Normal file
View File

@ -0,0 +1,51 @@
# Comment contribuer à Framadate funky ?
Il existe des tas de façons de contribuer à un logiciel libre comme Framadate. Vous pouvez __discuter__ avec d'autres personnes de ce que vous souhaiteriez voir naître dans le logiciel, __essayer__ de l'utiliser dans sa version expérimentale, __mettre en place une démo__ ou un service publiquement utilisable, __écrire__ des modifications de code, proposer de l'aide de toute sorte, [traduire les textes](cadrage/i18n.md), vérifier l'accessibilité, lire la [documentation d'architecture](../doc/cadrage/architecture.md) etc...
* Avoir un compte Framateam, et Framagit si vous souhaitez contribuer au code
* Examiner les tickets
Discuter avec les autres membres participant, sur un ticket en particulier et aussi dans les canaux de framateam
* Nous avons créé deux canaux: un pour les discussions générales, et un autre pour les discussions techniques de dev. Prenez soin de bien cibler le canal dans lequel vous communiquez afin de ne pas faire peur aux gens qui ne codent pas et qui souhaitent contribuer de toutes les autres façons.
* Une fois d'accord avec les autres, mettre à jour votre dépot de travail local. Voir [la doc d'installation / getting started](GETTING_STARTED.md) à ce sujet.
* Utiliser la branche `develop`
```
git checkout develop
git fetch
```
* choisir un ticket gitlab consacré à votre branche, si il n'en existe pas, le créer. Un ticket doit avoir un sujet suffisament petit pour pouvoir le réaliser dans un temps raisonnable. Faites des sous-tickets reliés pour les longues fonctionnalités. Le but des tickets n'étant pas de rester ad vitam dans la backlog, mais bien de montrer un avancement détaillé, avec un titre évocateur. "exit donc les tickets du genre 'ça ne marche pas' ou 'finir framadate'"
* Faire une branche dédiée à vos modifications en lui mettant un nom éloquent.
```
git checkout -b ma-description-de-fonctionnalite
```
* lisez les logs des commits les plus récents pour comprendre ce qui se passe.
* Faites des petits commits, avec un titre désignant précisément ce sur quoi vous progressez.
* Faire une merge request sur framagit qui sera soumise à la revue de code par les pairs du projet.
* Continuer à interagir avec les autres membres pour utiliser au mieux les savoir-faire de chacun et ne pas se marcher sur les pieds.
* Mettez toujours en avant la politesse et l'empathie, la collaboration n'en sera que meilleure.
* N'hésitez pas à contacter en direct les personnes avec lesquelles vous souhaitez avancer.
# Qui veut faire quoi ?
* maiwann : maquettes, UX
* tykayn : développeur front end, JS & styles
* newick : intégrateur dans la vraie vie
* llaq : plutôt HTML / css et un peu de développement en php
* talone : plutôt coté JS
* tcit : dev tout qui connait bien le backend de Framadate actuel
* pouhiou : soutien moral
* come_744 : git, découverte d'angular
* arnaldo : php, découverte du libre
* elbuffeto : l'intégration HTML/CSS, accessibilité
* l4pin : un peu de front JS, beaucoup de back
* wadouk : dev compilé (elm, haskell, scala)
* cbossard : dev (plutôt backend), java/javascript, avec un peu de temps en ce moment
* seraf : dev fullstack (plutôt front), JS (Angular, VueJS, Svelte), PHP (Symfony, ApiPlatform), Java (Spring)
# Liens utiles:
* Discussion : https://framateam.org/ux-framatrucs/channels/framadate
* Repo front/dev : https://framagit.org/framasoft/framadate/funky-framadate-front/tree/dev
* Repo back : https://framagit.org/framasoft/framadate/framadate
* Maquettes Zeplin : demander l'accès à maiwann
* La démo : https://framadate-api.cipherbliss.com/
* Vidéo de présentation au lancement de la refonte : https://nuage.maiwann.net/s/JRRHTR9D2akMAa7

20
doc/GETTING_STARTED.md Normal file
View File

@ -0,0 +1,20 @@
# Pour débuter - getting started
Clonez le dépot, installez les dépendances, et lancez le serveur local qui se recharge lorsque vous sauvegardez des fichiers dans le dépot. Les fois suivantes vous n'aurez qu'a lancer yarn start.
```bash
# clone the repo
git clone https://framagit.org/framasoft/framadate/funky-framadate-front.git
cd funky-framadate-front
# install yarn
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update && sudo apt-get install yarn
yarn install --pure-lockfile
yarn start
# and now check your browser http://localhost:4200
```
Pour interagir avec la base de données, il vous faudra également faire démarrer l'API Symfony de backend dans l'autre dépot, qui se lance par défaut sur le port 8000.
# Pas de config docker
Nous n'avons pas actuellement de config docker qui solutionnerait tout ça, les merge request sont les bienvenues! :)

View File

@ -0,0 +1,21 @@
# Accessibilité
Vérifiez toujours que ce que vous développez est Accessible pour des personnes aux handicaps divers tels que le préconise le WECAG.
https://www.w3.org/Translations/WCAG20-fr/
Quelques lignes à vérifier:
* Couleurs contrastées
* ordre des liens cohérent
* textes des liens éloquents
* textes alternatifs aux images les décrivant
* possibilité de changer la taille des textes et le thème visuel
* couleurs lisibles pour les personnes daltoniennes
* raccourcis claviers. alt+t pour passer a un autre thème visuel par exemple.
* attributs aria pour les lecteurs d'écran
* performance de chargement pour les petits débits
* server side rendering
* pas d'éléments perdus en dehors de l'écran, faites du mobile first
* parcimonie: si un élément n'est pas prévu pour être montré sur petit écran, ne le mettez pas sur grand écran sous prétexte qu'il y a plus de place.
* less is more
* système de recherche basique
N'ayez pas peur de demander de l'aide

View File

@ -0,0 +1,13 @@
# Architecture de Framadate
* Documentation de référence dans le dossier "doc" de ce dépot.
* Frontend généré avec Angular CLI
* chaque commit sur ce dépot passe les contrôles de format de Husky avant de pouvoir être envoyé.
* Doc sur l'api du nouveau backend à utiliser pour les appels REST du front funky : https://framagit.org/tykayn/date-poll-api/wikis/home
toujours se référer à la sortie de la commande de symfony `bin/console debug:router` du dépot back.
ancien backend en PHP sans framework ici pour mémoire : https://framagit.org/framasoft/framadate/framadate
# Schéma de base de données du backend
https://framagit.org/framasoft/framadate/funky-framadate-front/-/wikis/uploads/9d9a82cc6d7c6efea073b64d3667dc9b/framadate-api.png
png créé avec sqlfairy https://www.cipherbliss.com/exporter-une-visualisation-de-son-schma-sql/

View File

@ -0,0 +1,56 @@
// TODO: File to be deleted : just temporary documentation of backend API endpoints
------------------------------------------ ---------- -------- ------ ------------------------------------------------
Name Method Scheme Host Path
------------------------------------------ ---------- -------- ------ ------------------------------------------------
_twig_error_test ANY ANY ANY /_error/{code}.{_format}
admin_homepage_get_default GET ANY ANY /admin/
admin_homepage_clean_expired_polls GET ANY ANY /admin/polls/clean/{token}
admin_homepage_migrate_framadate GET ANY ANY /admin/polls/migrate
api_get_poll_comment GET ANY ANY /api/v1/poll/{id}/comments
api_new_comment POST ANY ANY /api/v1/poll/{id}/comment
api_poll_comments_delete DELETE ANY ANY /api/v1/poll/{id}/comments
api_page_home GET ANY ANY /page/
user_homepageget_default GET ANY ANY /user/
user_homepage_polls_send_by_email GET ANY ANY /user/{email}/polls/send-by-email
poll_index GET ANY ANY /poll/
poll_new GET|POST ANY ANY /poll/new
poll_show GET ANY ANY /poll/{id}
poll_edit GET|POST ANY ANY /poll/{id}/edit
poll_delete DELETE ANY ANY /poll/{id}
api_new_vote_stack POST ANY ANY /api/v1/poll/{id}/answer
api_update_vote_stack PATCH ANY ANY /api/v1/vote-stack/{id}/token/{modifierToken}
api_poll_votes_delete DELETE ANY ANY /api/v1/poll/{id}/votes/{accessToken}
api_get_all_polls GET ANY ANY /api/v1/poll/
api_get_poll GET ANY ANY /api/v1/poll/{id}
api_update_poll PUT ANY ANY /api/v1/poll/{id}/{token}
api_new_poll POST ANY ANY /api/v1/poll/
api_test-mail-poll GET ANY ANY /api/v1/poll/mail/test-mail-poll/{emailChoice}
api_poll_delete DELETE ANY ANY /api/v1/poll/{id}
api_check_slug_is_unique GET ANY ANY /api/v1/poll/slug/{slug}
api_get_admin_config GET ANY ANY /api/v1/poll/admin/{token}
overblog_graphql_endpoint ANY ANY ANY /api/graphql/
overblog_graphql_batch_endpoint ANY ANY ANY /api/graphql/batch
overblog_graphql_multiple_endpoint ANY ANY ANY /api/graphql/graphql/{schemaName}
overblog_graphql_batch_multiple_endpoint ANY ANY ANY /api/graphql/graphql/{schemaName}/batch
app.swagger GET ANY ANY /api/doc.json
------------------------------------------ ---------- -------- ------ ------------------------------------------------
/**
* WANTED CHANGES (seraf)
* -------------------------- -------- -------- ------ ------------------------------------------------
Name Method Scheme Host Path
-------------------------- -------- -------- ------ ------------------------------------------------
api_get_poll_comment GET ANY ANY /polls/{id}/comment
api_delete_poll_comments DELETE ANY ANY /polls/{id}/comment
api_user_polls_send_by_email GET ANY ANY /users/{email}/polls/send-by-email
api_get_user_polls GET ANY ANY /users/{email}/polls
api_get_poll_slug GET ANY ANY /polls/slug/{id}/{token}
api_clean_expired_polls GET ANY ANY /admin/clean-polls/{token}
api_test-mail-polls GET ANY ANY /polls/mail/test-mail-polls/{emailChoice}
api_update_vote_stack PATCH ANY ANY /polls/{slug}/answers/{pseudo}/token/{modifierToken}
-------------------------- -------- -------- ------ ------------------------------------------------
*/

13
doc/cadrage/i18n.md Normal file
View File

@ -0,0 +1,13 @@
# Internationalisation - i18n
Toutes les chaînes de texte doivent être disponible en minimum deux langues: Français et Anglais.
La documentation a été pensée pour être compréhensible en premier lieu par des personnes francophones, le projet étant issu de Framasoft et de personnes uniquement Francophones, nous avons jugé que c'était le moyen le plus efficace pour le faire grandir.
Voir les fichiers src/assets/i18n [EN.json](/src/assets/i18n/EN.json) et [FR.json](/src/assets/i18n/FR.json)
La traduction se base sur un système de clés-valeur.
Les clés sont entrées dans les templates html, et c'est la config d'Angular qui les traduit selon la langue demandée par le visiteur du site.
Chaque fichier de traduction est déclaré dans le [AppModule](../../src/app/app.module.ts) avec le module @ngx-translate. Examinez l'exemple pour rajouter votre propre traduction.
Utilisez des sous groupes dans vos traductions afin de mieux segmenter les chaines de caractère par page et selon le sens qu'elles sous tendent.

View File

@ -0,0 +1,109 @@
# Spécifications fonctionnelles de Framadate
[[_TOC_]]
![Diagramme des cas d'utilisations décrits dans la suite du document](uploads/uml/diagramme_cas_utilisation.png)
# Glossaire
| Terme | Signification |
| :--- | :--- |
| Créateur | Personne ayant créé le sondage |
| Participant | Personne invitée à participer ou ayant participé |
| Archivage | Empêcher votes et commentaires mais conserver les résultats |
| _Slug_ | Partie le l'URL identifiant un sondage de manière unique |
# Fonctionnalités actuellement dans framadate legacy et à conserver
## Généralités
Il existe deux types de sondage. Le type «dates» est adapté à la proposition de dates (voir [§plages horaires](#plages-horaires)) et le type «texte», plus généraliste, ne contient que du texte. Ce choix est l'un des premiers choix effectués lors de la création d'un nouveau sondage.
Deux types d'acteurs sont distingués : la personne ayant créé le sondage et les personnes qui répondent au sondage. Ces deux types d'acteurs n'ont pas les mêmes droits sur le sondage : la personne ayant créé le sondage est la seule ayant des droits d'administration sur celui-ci, en plus des droits des participants. En particulier, les droits d'administration permettent d'ajuster les droits des participants.
## Création d'un sondage
Il est nécessaire de fournir un email lors de la création du sondage. Cela permet aux créateurs de sondages de pouvoir obtenir via email la liste des sondages qu'ils ont créés.
Il est possible de choisir le _slug_ dans l'URL du sondage, à condition que ce _slug_ soit disponible.
Le sondage reste modifiable après sa création.
### Plages horaires
Les sondages de type «dates» permettent de proposer des jours et des plages horaires pour chaque journée.
Pour faciliter la saisie des dates proposées, il est demandé si les plages horaires sont les mêmes chaque jour ou si elles sont différentes selon les jours. Dans le premier cas, les plages horaires sont demandées uniquement pour le premier jour et sont automatiquement reproduites sur les autres jours.
Toujours pour faciliter la saisie des propositions, il est possible d'ajouter plusieurs jours consécutifs en sélectionnant le premier et le dernier jour plutôt qu'en sélectionnant chaque jour un à un.
## Participation à un sondage
### Votes
Les participants n'ont pas besoin de créer un compte ou de fournir leur email pour participer à un sondage.
Le créateur du sondage peut protéger le sondage par un mot de passe. Sans ce mot de passe, il est impossible de voir le sondage, donc impossible d'y voter. Attention cependant, le sondage est stocké en clair dans la base de données et ne bénéficie donc d'aucun chiffrement. *# Le mot de passe, lui, est-il chiffré?*
Le créateur du sondage choisit de permettre ou non les modifications des votes sur le sondage. Il a le choix entre les trois formules suivantes :
* ne pas permettre de modifier de réponse;
* permettre de modifier uniquement sa propre réponse a un sondage (modalités d'identification encore non déterminées);
* permettre de modifier toute réponse à un sondage (y compris celles des autres).
### Commentaires
Les participants au sondage ont la possibilité de créer des commentaires sur le sondage. Seule la personne ayant les droits d'administration sur le sondage peut modifier les commentaires.
## Résultats
Selon la configuration du sondage, les résultats peuvent n'être accessibles qu'à la personne ayant un accès d'administration ou bien être publics. Les personnes pouvant accéder aux résultats du sondage peuvent exporter ces résultats au format CSV.
## Emails
Lorsqu'une personne crée un sondage, un email reprenant les informations du sondage ainsi que les URL uniques servant à le modifier (lien d'administration) et à y participer (lien à transmettre aux participants) lui est envoyé. De plus, elle peut choisir de recevoir des emails lors d'un nouveau vote ou commentaire sur le sondage.
Une personne ayant créé au moins un sondage peut demander à recevoir par email la liste des sondages qu'elle a créés en utilisant cette adresse email.
## Stockage et export de données
Les données sont stockées en clair sur le serveur. L'archivage (resp. suppression) automatique des sondages est effectué via cronjob 90 (resp. 120) jours après sa création. Il est possible pour l'administrateur d'un sondage de modifier la durée avant archivage et la suppression automatique suit toujours de 30 jours la date d'archivage.
L'export d'un sondage et des résultats d'un sondage est possible au format CSV.
# Nouveautés principales
* Accessibilité renforcée.
* Traduction dynamique de toutes les phrases en choisissant la langue dans le menu.
* Adapté aussi bien sur mobile que grands écrans à haute ou faible densité de pixels.
* Anti-spam de commentaires.
* Anti-spam de vote.
* Tests unitaires et end-to-end.
* Couverture de test.
# Nouveautés secondaires
* Choix de réponses possibles. Proposer de ne répondre que «oui» ou rien, ou aller dans la nuance en proposant «oui», «peut-être», «non», « ? ». *# Redondance ou le choix de réponses possibles de la première phrase concerne un autre choix?*
* Insertion d'images dans le sondage de type texte, avec des URL uniquement. Une seule image par title possible ou rien.
* Thème sombre.
* Boutons pour copier dans le presse-papier les liens publics et privés / admin des sondages.
* Limiter le nombre de participants maximum
# Idées pour de futures améliorations (pertinence à vérifier)
* Gagner en vie privée en chiffrant certaines informations?
* À réfléchir : permettre à Framadate de faire entrer à des gens plusieurs plages de temps de disponibilité et le service déduit quelles sont les plages de temps favorables (calcul d'intersection sur des lignes discontinues). Cela pourrait être avec divers niveaux de détail. Comme https://omnipointment.com/ (qui est un logiciel privateur)
* SSO du fédiverse?

14
doc/customisation.md Normal file
View File

@ -0,0 +1,14 @@
# Personnalisation
Vous pouvez modifier les variables d'environnement afin de modifier le logo et le titre de votre installation.
Ce logiciel étant libre et sous la licence GNU Affero V3 vous pouvez bien entendu le modifier à volonté. Voici donc un guide concernant le titre et le logo.
Modifiez le fichier `src/environment.prod.ts`, et remplacez le contenu des variables `appTitle` (un texte, pas forcément un seul mot) et `appLogo` (une URL absolue ou relative d'image)
Et voilà!
Quand vous builderez votre app, vous verrez ces valeurs dans la barre titre, incluses par le composant HeaderComponent automatiquement.
Vous pouvez modifier les valeurs dans le fichier `src/environment.ts` pour voir ce que cela donne en environnement de développement en lancant l'app avec `npm start` ou `yarn start`.
#Customization
Change the content of the file `src/environment.prod.ts`, and the content of the vars `appTitle` (any text) and `appLogo` (any picture URL, relative or absolute) .
And voilà!

View File

@ -0,0 +1,39 @@
Framadate suivi - (__date de réunion__)
========================================
###### tags: #framadate, #suivi
> Participants à la réunion:
*
*
*
## État des lieux
### > Où on en est
https://framagit.org/framasoft/framadate/funky-framadate-front/-/boards
> Quel est le projet ? Quels sont les enjeux ?
> Quelles méthodes de travail ?
> Quel niveau de qualité ? standards et de bonnes pratiques ?
> *
> Quelles priorités ?
> *
> Qui veut faire quoi ?
> *
## Notes de réunion
-
## Trucs à faire
-
## Décisions prises
-
## Ressources
* Discussion : https://framateam.org/ux-framatrucs/channels/framadate
* Repo front/dev : https://framagit.org/framasoft/framadate/funky-framadate-front/tree/dev
* Repo back : https://framagit.org/framasoft/framadate/framadate
* Maquettes Zeplin : demander l'accès à maiwann
* La démo : https://framadate-api.cipherbliss.com/
* Vidéo de présentation : https://nuage.maiwann.net/s/JRRHTR9D2akMAa7

129
doc/reunions/2019_08_09.md Normal file
View File

@ -0,0 +1,129 @@
# notes du 9/8/2019 avec Mumble
À discuter :
discussion via framatalk ? plutôt mumble !
Se connecter à mumble.tcit.fr. Il n'a pas de mot de passe et il y a un salon Framadate ouvert.
qui peut/veut faire quoi ?
tykayn: dev front end, JS & styles
newick : intégrateur dans la vraie vie
llaq : plutôt HTML
talone : plutôt coté JS
on ne s'occupe toujours que du front ?
[newick] Est-ce que tout le monde est ok sur le fait de faire que le front et pas tout ?
llaq : pas de souci avec le back, donc pourquoi le changer ?
Ok on y touche pas
repartir de 0 ?
Je parle de funky framadate
llaq : Pourquoi pas recommencer car c'est bien différent des maquettes
newick : On est toujours en mode "MVP", on fait ça pour tester les maquettes et ensuite seulement on le connecte au back-end ?
maiwann : Ouip tant que ça reste cohérent niveau expérience de l'utilisateur
newick : C'est plutot coté JS qu'il faut voir… talone ?
Talone : on peut faire des choses, je maitrise pas php
llaq : moi non plus
talone : il y a des contributeurices de framadate ici ?
maiwann : nope :x
newick : on peut avancer comme ça, surtout sur le front et ensuite on verra le back ?
talone & llaq : oui on peut faire ça
[tentative de tykayn de nous rejoindre]
pourquoi y'a des pages php dans le dossier front end ?
newick : C'est dans le dossier principal, pas dans funky
maiwann : donc a priori rien d'obligatoire ?
API de backend existe t elle?
llaq : nope ^_^
Sass, css, autre ?
https://css2sass.herokuapp.com/ pour convertir le css actuel vers du sass
intérêt de Bootstrap / frameworks ?
Angular cli pour se concentrer sur des composants front end
Boostrap a priori c'est pas forcément nécessaire, newick assez à l'aise pour ne pas en avoir besoin dans HTML / CSS
Composants <3 Atomic design <3
tykayn : propose de faire une démo
(Beaucoup de discussion sur les framework front, j'ai pas tout compris ce qu'il se passait donc mes notes sont très limitées !)
En gros, on fait du Angular et chacun intègre son truc indépendamment
Invitation sur funkyframadate, on repart de 0 !
La suite
remplissons le wiki et les board d'issues
on zieute la démo et on voit ce qu'on fait ensuite
--------
# Questions notées par Come.
Suite au visionnage de la vidéo de présentation de maiwann (Framadate_Presentation_maquettes.flv), des questions ont été levées :
* Quel est l'intérêt de différentier les dates limites de modification et vote? Pourquoi appelle-t-on cela archivage?
* Peut-on écrire plus explicitement que le nom du créateur, s'il est renseigné, est affiché à ceux qui répondent?
* Lors de la validation du vote, peut-on mettre davantage en avant l'importance de l'URL d'édition afin d'éviter la fermeture machinale (par réflexe) du popup?
* Pour la vue des réponses, pourquoi ne pas mettre pour tous les utilisateurs les petites icônes actuellement réservées aux daltoniens? On économiserait alors un champ.
* Quitte à faire la chasse aux clics, pour les menus déroulants à deux options (du type «je veux» / «je ne veux pas») qui nécessitent deux clics pour commuter, pourquoi pas une commutation en un clic; comme une check-box mais avec du texte à la place du check? Bien-sûr, ceci à condition que les champs conditionnels ne soient pas vidés lorsqu'ils disparaissent.
* Y a-t-il des champs obligatoires? Si oui, comment les matérialiser?
* En cas d'erreur à la validation du formulaire (pour cause de champ vide ou format incorrect par exemple), on affiche systématiquement le message d'erreur en orange (cf. écran sondage_date_intervalle) en dessous du champ concerné?
* Quel est l'état de focus sur les champs? Celui par défaut du navigateur?
* Étant donné qu'il y a plusieurs étapes dans la création du sondage, ne faudrait-il pas les indiquer au début de la page? Ex : 1/3, 2/3, 3/3 (je tire ça des bonnes pratiques opquast)
* Dans le récapitulatif à la fin, pourquoi ne rappelle-t-on pas également le titre et la description du sondage créé?
Les boutons:
* Quel est l'état de focus sur les boutons?
* Sur l'écran «Mot de passe», le bouton «Voir» est-il immédiatement actif ou on attend d'avoir tapé la première lettre? Une fois qu'on a cliqué sur «Voir», est-ce que le contenu du bouton devient «Masquer»?
* Dans ta vidéo tu précises que le popup pour ajouter l'intervalle de dates se situe en dessous du calendrier pour qu'on puisse voir les dates préalablement sélectionnées, mais je pense que sur mobile ça va être compliqué : ça signifierait que le popup se situe très bas dans l'écran, il n'y aurait que le titre visible sur les téléphones de petite taille.
* Dans les écrans de réponse au sondage date, quelle est la règle pour l'abbréviation des dates? Des fois c'est le jour qui est abrégé, des fois le mois.
* On part sur une version desktop à partir de 768px? Je demande, car tes maquettes desktop font 602 px de large et je ne sais pas si c'est délibéré ou non.

View File

@ -0,0 +1,68 @@
Framadate suivi - Avril 2020
==
###### tags: `framadate` `suivi`
> Participants à la réunion: Maiwann / Tykain / Côme / Seraf
## État des lieux
> Où on en est sur la version funky ?
https://framagit.org/framasoft/framadate/funky-framadate-front/-/boards
> Quel est le projet ? Quels sont les enjeux ?
- Avoir un truc utilisable (sur téléphone !)
- Graphiquement plus élégant
> Quelles méthodes de travail ?
- Une refonte ergonomique avant de faire des tests utilisateurs pour vérifier que la nouvelle interface est chouette
> Quel niveau de qualité ? standards et de bonnes pratiques ?
- Une refonte graphique, utilisable sur mobile
- De l'internationalisation
- Un logiciel accessible
> Quelles priorités ?
- Cycle de vote à finir
> Qui veut faire quoi ?
## Notes de réunion
> Le nom semble peu approprié depuis l'apparition des nouvelles fonctionnalités.
Suggestions :
- Framapool
- FramEnquête
- Framasondage
> Idée de nouvelle fonctionnalité (pour plus tard hein :D )
- Chaque utilisateur indique ses disponibilités et le service renvoie les plages pour lesquelles le plus de personnes sont disponibles.
## Trucs à faire
- Gitlab CI : exécuter les tests automatiquement
- envoi de mail au créateur du sondage
- cafouillage entre front & back quand édition d'un sondage (à préciser par tykain)
- vérifier l'accesibilité du formulaire
- prompt de modale pour accès par mot de passe a un sondage privé
## Remarques
Il est difficile d'entrer dans le projet en absence de spécifications écrites. (seraf)
## Décisions prises
- Vérifier que chacun.e arrive à faire tourner le projet
- Chacune choisit des issues / tickets adaptés à ce qu'il a envie de faire
## Ressources
* Discussion : https://framateam.org/ux-framatrucs/channels/framadate
* Repo front/dev : https://framagit.org/framasoft/framadate/funky-framadate-front/tree/dev
* Repo back : https://framagit.org/framasoft/framadate/framadate
* Maquettes Zeplin : https://scene.zeplin.io/project/5d4d83d68866d6522ff2ff10
* La démo : https://framadate-api.cipherbliss.com/
* Vidéo de présentation : https://nuage.maiwann.net/s/JRRHTR9D2akMAa7

32
e2e/protractor.conf.js Normal file
View File

@ -0,0 +1,32 @@
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.json')
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

23
e2e/src/app.e2e-spec.ts Normal file
View File

@ -0,0 +1,23 @@
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('Welcome to framadate!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
});
});

11
e2e/src/app.po.ts Normal file
View File

@ -0,0 +1,11 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get(browser.baseUrl) as Promise<any>;
}
getTitleText() {
return element(by.css('app-root h1')).getText() as Promise<string>;
}
}

9
e2e/tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es5",
"types": ["jasmine", "jasminewd2", "node"]
}
}

5
index.lokalize Normal file
View File

@ -0,0 +1,5 @@
[General]
LangCode=fr_FR
PotBaseDir=src/assets/i18n
ProjectID=funky-framadate-front
TargetLangCode=fr_FR

View File

@ -0,0 +1,14 @@
<!--
Collection name attribute represents the name of the menu, e.g., to use menu "File" use "file" or "Help" use "help". You can add new menus.
If you type a relative script file beware that this script is located in $XDG_DATA_HOME/applicationname/
The following example adds an action with the text "Export..." into the "File" menu
<KrossScripting>
<collection name="file" text="File" comment="File menu">
<script name="export" text="Export..." comment="Export content" file="export.py" />
</collection>
</KrossScripting>
-->

4
main.lqa Normal file
View File

@ -0,0 +1,4 @@
<?xml version='1.0' encoding='UTF-8'?>
<qa version="1.0">
<category name="default"/>
</qa>

165
mocks/db.json Normal file
View File

@ -0,0 +1,165 @@
{
"owners": [
{ "id": 1, "email": "titi@gafam.com", "pseudo": "TITI", "role": "REGISTERED" },
{ "id": 2, "email": "toto@gafam.com", "pseudo": "TOTO", "role": "REGISTERED" }
],
"polls": [
{
"id": 1,
"slug": "picnic",
"configuration": {
"id": 1,
"isAboutDate": true,
"isProtectedByPassword": false,
"isOwnerNotifiedByEmailOnNewVote": false,
"isOwnerNotifiedByEmailOnNewComment": false,
"isMaybeAnswerAvailable": true,
"areResultsPublic": true,
"dateCreated": "2020-05-17",
"expires": "2020-12-31"
},
"ownerId": 1,
"title": "Quelle date pour le picnic ?",
"description": "Gros badass picnic en plein air ! Come on !"
},
{
"id": 2,
"slug": "vacances",
"configuration": {
"id": 2,
"isAboutDate": true,
"isProtectedByPassword": false,
"isOwnerNotifiedByEmailOnNewVote": false,
"isOwnerNotifiedByEmailOnNewComment": false,
"isMaybeAnswerAvailable": true,
"areResultsPublic": true,
"dateCreated": "2020-05-17",
"expires": "2020-11-30"
},
"ownerId": 2,
"title": "On fait quoi pendant les vacances ?",
"description": "Vacances en famille"
}
],
"choices": [
{
"id": 1,
"pollId": 1,
"pollSlug": "picnic",
"label": "samedi",
"participants": [
[
"YES",
[
{ "pseudo": "TOTO", "token": "TOTO-TOKEN" },
{ "pseudo": "TITI", "token": "TITI-TOKEN" }
]
],
["NO", []],
["MAYBE", [{ "pseudo": "TATA", "token": "TATA-TOKEN" }]]
]
},
{
"id": 2,
"pollId": 1,
"pollSlug": "picnic",
"label": "dimanche",
"participants": [
[
"YES",
[
{ "pseudo": "TATA", "token": "TATA-TOKEN" },
{ "pseudo": "TETE", "token": "TETE-TOKEN" }
]
],
["NO", [{ "pseudo": "TOTO", "token": "TOTO-TOKEN" }]],
["MAYBE", [{ "pseudo": "TITI", "token": "TITI-TOKEN" }]]
]
},
{
"id": 3,
"pollId": 2,
"pollSlug": "vacances",
"label": "bateau",
"participants": [
[
"YES",
[
{ "pseudo": "TOTO", "token": "TOTO-TOKEN" },
{ "pseudo": "TITI", "token": "TITI-TOKEN" }
]
],
["NO", [{ "pseudo": "TETE", "token": "TETE-TOKEN" }]],
["MAYBE", [{ "pseudo": "TATA", "token": "TATA-TOKEN" }]]
]
},
{
"id": 4,
"pollId": 2,
"pollSlug": "vacances",
"label": "montagne",
"participants": [
[
"YES",
[
{ "pseudo": "TOTO", "token": "TOTO-TOKEN" },
{ "pseudo": "TITI", "token": "TITI-TOKEN" }
]
],
["NO", [{ "pseudo": "TETE", "token": "TETE-TOKEN" }]],
["MAYBE", [{ "pseudo": "TATA", "token": "TATA-TOKEN" }]]
]
},
{
"id": 5,
"pollId": 2,
"pollSlug": "vacances",
"label": "quad",
"participants": [
[
"YES",
[
{ "pseudo": "TOTO", "token": "TOTO-TOKEN" },
{ "pseudo": "TITI", "token": "TITI-TOKEN" }
]
],
["NO", [{ "pseudo": "TETE", "token": "TETE-TOKEN" }]],
["MAYBE", [{ "pseudo": "TATA", "token": "TATA-TOKEN" }]]
]
}
],
"comments": [
{
"id": 1,
"pollId": 1,
"pollSlug": "picnic",
"content": "Les picnics, cest trop bien, jadore!",
"author": "TATA",
"dateCreated": 1589111111111
},
{
"id": 2,
"pollId": 1,
"pollSlug": "picnic",
"content": "Oué, grave!",
"author": "TETE",
"dateCreated": 1589222222222
},
{
"id": 3,
"pollId": 2,
"pollSlug": "vacances",
"content": "Désolé je pourrai pas être là, mais je penserai bien à vous. Mamie",
"author": "MAMIE",
"dateCreated": 1589333333333
},
{
"id": 4,
"pollId": 2,
"pollSlug": "vacances",
"content": "Arf, trop dommage.",
"author": "Tom",
"dateCreated": 1589444444444
}
]
}

8
mocks/routes.json Normal file
View File

@ -0,0 +1,8 @@
{
"/api/v1/*": "/$1",
"/owners/:email/": "/owners?email=:email",
"/polls": "/polls?_expand=owner&_embed=choices&_embed=comments",
"/polls/:slug": "/polls?slug=:slug&_expand=owner&_embed=choices&_embed=comments",
"/polls/:slug/choices": "/choices?pollSlug=:slug",
"/polls/:slug/comments": "/comments?pollSlug=:slug"
}

20900
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

121
package.json Normal file
View File

@ -0,0 +1,121 @@
{
"name": "framadate-funky-frontend",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build:prod": "ng build --prod",
"build:prod:stats": "ng build --prod --stats-json",
"build:prod:gitlabpage": "ng build --prod --baseHref=/framadate/funky-framadate-front/",
"build:prod:demobliss": "ng build --prod --baseHref=https://framadate-api.cipherbliss.com",
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "jest --ci",
"lint": "prettier --write \"src/**/*.{js,jsx,ts,tsx,md,html,css,scss}\"",
"e2e": "ng e2e",
"format:check": "prettier --list-different \"src/{app,environments,assets}/**/*{.ts,.js,.json,.css,.scss}\"",
"format:all": "prettier --write \"src/**/*.{js,jsx,ts,tsx,md,html,css,scss}\"",
"trans": "ng xi18n --output-path=src/locale --i18n-locale=fr",
"compodoc": "compodoc -p tsconfig.app.json",
"mock:server": "json-server --port 8000 --watch ./mocks/db.json --routes ./mocks/routes.json",
"start:proxy": "ng serve --proxy-config proxy.conf.json",
"start:proxymock": "concurrently --kill-others \"yarn mock:server\" \"yarn start:proxy\"",
"i18n:init": "ngx-translate-extract --input ./src --output ./src/assets/i18n/template.json --key-as-default-value --replace --format json",
"i18n:extract": "ngx-translate-extract --input ./src --output ./src/assets/i18n/{en,da,de,fi,nb,nl,sv}.json --clean --format json"
},
"private": false,
"dependencies": {
"@angular/animations": "^9.1.1",
"@angular/cdk": "^9.2.2",
"@angular/common": "^9.0.7",
"@angular/compiler": "^9.0.7",
"@angular/core": "^9.0.7",
"@angular/forms": "^9.0.7",
"@angular/localize": "^9.1.1",
"@angular/material": "^9.2.4",
"@angular/platform-browser": "^9.0.7",
"@angular/platform-browser-dynamic": "^9.0.7",
"@angular/router": "^9.0.7",
"@biesbjerg/ngx-translate-extract": "^7.0.3",
"@biesbjerg/ngx-translate-po-http-loader": "^3.1.0",
"@fullcalendar/core": "^4.4.0",
"@ngx-translate/core": "^12.1.2",
"@ngx-translate/http-loader": "^5.0.0",
"angular-date-value-accessor": "^1.0.2",
"axios": "^0.19.2",
"bulma": "^0.9.0",
"bulma-switch": "^2.0.0",
"chart.js": "^2.9.3",
"fork-awesome": "^1.1.7",
"ng2-charts": "^2.3.0",
"ngx-clipboard": "^13.0.0",
"ngx-markdown": "^9.0.0",
"ngx-webstorage": "^5.0.0",
"primeng": "^9.0.6",
"quill": "^1.3.7",
"rxjs": "^6.5.5",
"rxjs-compat": "^6.5.5",
"short-unique-id": "^3.0.3",
"stream": "^0.0.2",
"tslib": "<2.0.0",
"zone.js": "^0.10.3"
},
"devDependencies": {
"@angular-builders/jest": "^9.0.1",
"@angular-devkit/build-angular": "^0.901.2",
"@angular/cli": "^9.1.2",
"@angular/compiler-cli": "^9.1.1",
"@angular/language-service": "^9.0.7",
"@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.5",
"@babel/preset-typescript": "^7.9.0",
"@compodoc/compodoc": "^1.1.11",
"@types/jest": "^26.0.0",
"@types/node": "^14.0.1",
"@typescript-eslint/eslint-plugin": "^3.0.0",
"@typescript-eslint/parser": "^3.0.0",
"babel-jest": "^26.0.0",
"concurrently": "^5.2.0",
"eslint": "^7.0.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
"husky": "^4.2.5",
"jest": "^26.0.0",
"jest-environment-jsdom-sixteen": "^1.0.3",
"jest-preset-angular": "^8.1.3",
"json-server": "^0.16.1",
"lint-staged": "^10.1.7",
"prettier": "^2.0.5",
"protractor": "~7.0.0",
"ts-jest": "^26.0.0",
"ts-mockito": "^2.5.0",
"ts-node": "^8.10.1",
"typescript": "<3.9.0"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,md,html,css,scss}": [
"prettier --write",
"git add"
],
"*.js": [
"prettier --write"
]
},
"jest": {
"preset": "jest-preset-angular",
"setupFilesAfterEnv": [
"<rootDir>/src/jest.setup.ts"
],
"testEnvironment": "jest-environment-jsdom-sixteen",
"transform": {
"^.+\\.(ts|html)$": "ts-jest",
"^.+\\.jsx?$": "babel-jest"
}
}
}

11
proxy.conf.json Normal file
View File

@ -0,0 +1,11 @@
{
"/api/v1/*": {
"target": "http://localhost:8000",
"secure": false,
"pathRewrite": {
"^/api/v1": ""
},
"changeOrigin": false,
"logLevel": "debug"
}
}

View File

@ -0,0 +1,14 @@
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
import {routes} from "./routes-framadate";
@NgModule({
imports: [
RouterModule.forRoot(routes, {
// enableTracing: true, // <-- debugging purposes only
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@ -0,0 +1,23 @@
<mat-sidenav-container (backdropClick)="sidenav.toggle()">
<mat-sidenav #sidenav mode="side">
<app-navigation></app-navigation>
</mat-sidenav>
<mat-sidenav-content>
<div id="big_container" [class]="themeClass">
<div class="container">
<app-header [appTitle]="appTitle" [appLogo]="appLogo"></app-header>
<main>
<router-outlet></router-outlet>
<div *ngIf="devModeEnabled">
<br />
<mat-slide-toggle (change)="sidenav.toggle()" label="dev menu"> </mat-slide-toggle> menu
développeur
</div>
</main>
<app-footer></app-footer>
<app-feedback></app-feedback>
</div>
</div>
</mat-sidenav-content>
</mat-sidenav-container>

View File

View File

@ -0,0 +1,31 @@
import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule],
declarations: [AppComponent],
}).compileComponents();
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'framadate'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('framadate');
});
it('should render title in a h1 tag', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Welcome to framadate!');
});
});

62
src/app/app.component.ts Normal file
View File

@ -0,0 +1,62 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { environment } from '../environments/environment';
import { Theme } from './core/enums/theme.enum';
import { LanguageService } from './core/services/language.service';
import { ThemeService } from './core/services/theme.service';
import { MockingService } from './core/services/mocking.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit, OnDestroy {
public appTitle: string = environment.appTitle;
public appLogo: string = environment.appLogo;
public themeClass: string;
public isSidebarOpened = false;
public devModeEnabled = !environment.production;
private themeSubscription: Subscription;
constructor(
private titleService: Title,
private themeService: ThemeService,
private languageService: LanguageService // private mockingService: MockingService
) {}
ngOnInit(): void {
if (!environment.production) {
this.appTitle += ' [DEV]';
}
this.titleService.setTitle(this.appTitle);
this.languageService.configureAndInitTranslations();
this.themeSubscription = this.themeService.theme.subscribe((theme: Theme) => {
switch (theme) {
case Theme.DARK:
this.themeClass = 'theme-dark-crystal';
break;
case Theme.CONTRAST:
this.themeClass = 'theme-hot-covid';
break;
case Theme.RED:
this.themeClass = 'theme-hot-covid';
break;
default:
this.themeClass = 'theme-light-watermelon';
}
});
}
ngOnDestroy(): void {
if (this.themeSubscription) {
this.themeSubscription.unsubscribe();
}
}
public toggleSidebar(status: boolean): void {
this.isSidebarOpened = status === true;
}
}

71
src/app/app.module.ts Normal file
View File

@ -0,0 +1,71 @@
import { CommonModule, registerLocaleData } from '@angular/common';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import localeEn from '@angular/common/locales/en';
import localeFr from '@angular/common/locales/fr';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserModule, Title } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import {
MissingTranslationHandler,
MissingTranslationHandlerParams,
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { ClipboardModule } from 'ngx-clipboard';
import { MarkdownModule } from 'ngx-markdown';
import { NgxWebstorageModule } from 'ngx-webstorage';
import { environment } from '../environments/environment';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CoreModule } from './core/core.module';
import { SharedModule } from './shared/shared.module';
registerLocaleData(localeEn, 'en-EN');
registerLocaleData(localeFr, 'fr-FR');
export class MyMissingTranslationHandler implements MissingTranslationHandler {
public handle(params: MissingTranslationHandlerParams): string {
return `MISSING TRANSLATION FOR [${params.key}]`;
}
}
export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
return new TranslateHttpLoader(http);
}
@NgModule({
declarations: [AppComponent],
imports: [
AppRoutingModule,
BrowserAnimationsModule,
BrowserModule,
ClipboardModule,
CommonModule,
CoreModule,
FormsModule,
HttpClientModule,
MarkdownModule.forRoot(),
NgxWebstorageModule.forRoot({ prefix: environment.localStorage.key }),
SharedModule,
TranslateModule.forRoot({
defaultLanguage: 'FR',
loader: {
provide: TranslateLoader,
useFactory: HttpLoaderFactory,
deps: [HttpClient],
},
missingTranslationHandler: {
provide: MissingTranslationHandler,
useClass: MyMissingTranslationHandler,
},
useDefaultLang: true,
}),
],
providers: [Title, TranslateService],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@ -0,0 +1,30 @@
<footer class="footer">
<div class="content has-text-centered">
<p>
<img class="app-logo logo" *ngIf="env.appLogo" src="{{ env.appLogo }}" alt="{{ env.appTitle }}" />
{{ env.appTitle }} - {{ env.appVersion }} - libérez vos sondages. <i class="fa fa-copyleft"></i> Logiciel
libre sous licence AGPL v3.
<app-theme-selector></app-theme-selector>
<a href="https://framagit.org/framasoft/framadate/funky-framadate-front">
<i class="fa fa-gitlab"></i> Sources</a
>
|
<a href="https://framagit.org/framasoft/framadate/funky-framadate-front/-/wikis/home">
<i class="fa fa-book"></i>
Documentation
</a>
|
<a href="https://framateam.org/ux-framatrucs/channels/framadate">
<i class="fa fa-comment"></i>
canal de discussion Framateam
</a>
|
<a href="https://riot.im/app/#/room/#framadate:matrix.org">
<i class="fa fa-matrix-org"></i>
canal Matrix
</a>
</p>
</div>
</footer>

View File

@ -0,0 +1,4 @@
.app-logo {
max-width: 5em;
max-height: 5em;
}

View File

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FooterComponent } from './footer.component';
describe('FooterComponent', () => {
let component: FooterComponent;
let fixture: ComponentFixture<FooterComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [FooterComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FooterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,14 @@
import { Component, OnInit } from '@angular/core';
import { environment } from '../../../../environments/environment';
@Component({
selector: 'app-footer',
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss'],
})
export class FooterComponent implements OnInit {
public env = environment;
constructor() {}
ngOnInit(): void {}
}

View File

@ -0,0 +1,49 @@
<header class="big-header">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" routerLink="/">
<img class="app-logo logo" *ngIf="appLogo" src="{{ appLogo }}" alt="{{ appTitle }}" />
<span class="app-title title is-2">
{{ appTitle }}
</span>
<span class="dev-env button has-background-success" *ngIf="!env.production">
<i>
(dev)
</i>
</span>
</a>
<a
role="button"
class="navbar-burger burger"
aria-label="menu"
aria-expanded="false"
data-target="navbarBasicExample"
[ngClass]="{ 'has-background-primary': mobileMenuVisible }"
(click)="mobileMenuVisible = !mobileMenuVisible"
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item btn btn--primary" routerLink="administration" routerLinkActive="is-active">
<i class="fa fa-plus-circle"></i>
<span>
{{ 'config.title' | translate }}
</span>
</a>
</div>
<div class="navbar-end">
<app-language-selector></app-language-selector>
</div>
</div>
<div class="mobile-menu" *ngIf="mobileMenuVisible">
menu mobile
</div>
</nav>
</header>

View File

@ -0,0 +1,17 @@
:host {
header {
nav {
padding-right: 1em;
.fa {
margin-right: 1ch;
}
}
.container {
padding: 0;
}
}
.app-logo {
max-width: 5em;
max-height: 5em;
}
}

View File

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { HeaderComponent } from './header.component';
describe('HeaderComponent', () => {
let component: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [HeaderComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,25 @@
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { SettingsComponent } from '../../../shared/components/settings/settings.component';
import { User } from '../../models/user.model';
import { ModalService } from '../../services/modal.service';
import { UserService } from '../../services/user.service';
import { environment } from '../../../../environments/environment';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
})
export class HeaderComponent implements OnInit {
public _user: Observable<User> = this.userService.user;
public env = environment;
@Input() public appTitle: string = 'FramaDate Funky';
@Input() public appLogo: string;
mobileMenuVisible = false;
constructor(private userService: UserService) {}
public ngOnInit(): void {}
}

View File

@ -0,0 +1,69 @@
<section class="hero">
<div class="hero-body">
<div class="container">
<h1 class="title">
{{ 'home.title' | translate }}
{{ env.appTitle }}
</h1>
<div class="columns">
<div class="column">
<h2 class="subtitle">
{{ 'home.subtitle' | translate }}
</h2>
</div>
<div class="column">
<h2 class="subtitle">
{{ 'home.search_title' | translate }}
</h2>
</div>
</div>
<div class="columns">
<div class="column">
<a role="button" class="button is-fullwidth is-primary" routerLink="administration">
<i class="fa fa-plus-circle"></i>
{{ 'home.create_button' | translate }}
</a>
<img src="assets/img/kind/date.jpeg" alt="sondage date" />
</div>
<div class="column">
<a role="button" class="button is-fullwidth is-primary" routerLink="user/polls">
<i class="fa fa-search"></i>
{{ 'home.search_button' | translate }}
</a>
<img src="assets/img/kind/classic.jpeg" alt="sondage date" />
</div>
</div>
<div class="column">
<img src="assets/img/undraw_group_selfie_ijc6.svg" alt="image WIP" />
<p>
{{
'SENTENCES.framadate-is-an-online-service-for-planning-an-appointment-or-making-a-decision-quickly-and-easily'
| translate
}}
{{ 'SENTENCES.here-is-how-it-works' | translate }}
{{ 'SENTENCES.send-the-poll-link-to-your-friends-or-colleagues' | translate }}
</p>
<p>
{{ 'SENTENCES.what-is-framadate' | translate }}
{{ 'SENTENCES.view-an-example' | translate }}
{{ 'SENTENCES.framadate-is-licensed-under-the' | translate }}
<span class="licence">
GNU Affero v3 Licence
</span>
</p>
<p>
{{ 'SENTENCES.grow-your-own' | translate }}
{{
'SENTENCES.if-you-want-to-install-the-software-for-your-own-use-and-thus-increase-your-independence-we-can-help'
| translate
}}
{{
'SENTENCES.to-participate-in-the-software-development-suggest-improvements-or-simply-download-it-please-visit'
| translate
}}
{{ 'SENTENCES.the-development-site' | translate }}
</p>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,6 @@
:host {
text-align: center;
a .fa {
margin-right: 1ch;
}
}

View File

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { HomeComponent } from './home.component';
describe('HomeComponent', () => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [HomeComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { environment } from '../../../../environments/environment';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
})
export class HomeComponent {
public env = environment;
}

View File

@ -0,0 +1,13 @@
<div class="home_link">
<a class="button" routerLink="/" aria-roledescription="home">
<h1>
<span class="logo_first">Frama</span>
<span class="logo_second">Sondage</span>
</h1>
</a>
<a class="button legend" href="https://framasoft.org/" target="_blank">
proposé par
<span class="legend_first">Frama</span>
<span class="legend_second">soft</span>
</a>
</div>

View File

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LogoComponent } from './logo.component';
describe('LogoComponent', () => {
let component: LogoComponent;
let fixture: ComponentFixture<LogoComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [LogoComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LogoComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-logo',
templateUrl: './logo.component.html',
styleUrls: ['./logo.component.scss'],
})
export class LogoComponent {}

View File

@ -0,0 +1,62 @@
<nav class="has-background-light">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link"> Tous les sondages </a>
<div class="navbar-dropdown">
<a
class="navbar-item"
*ngFor="let poll of _pollsAvailables | async"
routerLink="{{ '/poll/' + poll.slug + '/consultation' }}"
routerLinkActive="is-active"
>
« {{ poll.slug }} »
</a>
</div>
</div>
<hr />
<div class="navbar-item has-dropdown is-hoverable is-warning" *ngIf="devModeEnabled">
<a class="navbar-link"> Modules </a>
<div class="navbar-dropdown">
<a class="navbar-item" routerLink="oldstuff" routerLinkActive="is-active">
Old stuff
</a>
<a class="navbar-item" routerLink="administration" routerLinkActive="is-active">
Administration
</a>
<a class="navbar-item" routerLink="consultation" routerLinkActive="is-active">
Consultation
</a>
<a class="navbar-item" routerLink="participation" routerLinkActive="is-active">
Participation
</a>
</div>
<hr />
<a class="navbar-item" routerLink="/poll/inexistentPoll/consultation" routerLinkActive="is-active">
« inexistentPoll »
</a>
<hr />
<a class="button is-block" routerLink="oldstuff/step/creation" routerLinkActive="active"> Création </a>
<a class="button is-block" routerLink="oldstuff/step/date" routerLinkActive="active"> Les Dates </a>
<a class="button is-block" routerLink="oldstuff/step/answers" routerLinkActive="active"> Réponses </a>
<a class="button is-block" routerLink="oldstuff/step/visibility" routerLinkActive="active"> Visibilité </a>
<a class="button is-block" routerLink="oldstuff/step/resume" routerLinkActive="active"> Résumé </a>
<a class="button is-block" routerLink="oldstuff/step/end" routerLinkActive="active"> Confirmation </a>
<a class="button is-block" routerLink="oldstuff/step/admin"> Administration </a>
<hr />
<a class="button is-block" routerLink="oldstuff/step/kind" routerLinkActive="active"> Page démo </a>
<a class="button is-block" routerLink="oldstuff/vote/poll/id/1" routerLinkActive="active"> Sondage 1 </a>
<a class="button is-block" routerLink="oldstuff/vote/poll/id/2" routerLinkActive="active"> Sondage 2 </a>
<a class="button is-block" routerLink="oldstuff/vote/poll/id/3" routerLinkActive="active">
Sondage 3 (dessins animés)
</a>
<a class="button is-block" routerLink="oldstuff/graphic/toto" routerLinkActive="active"> Graphique </a>
</div>
<a class="button is-block" routerLink="oldstuff/step/home" routerLinkActive="active">
<i class="fa fa-home" aria-hidden="true"></i> Accueil
</a>
</nav>

View File

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NavigationComponent } from './navigation.component';
describe('NavigationComponent', () => {
let component: NavigationComponent;
let fixture: ComponentFixture<NavigationComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [NavigationComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(NavigationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,20 @@
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Poll } from '../../../models/poll.model';
import { MockingService } from '../../../services/mocking.service';
import { environment } from '../../../../../environments/environment';
@Component({
selector: 'app-navigation',
templateUrl: './navigation.component.html',
styleUrls: ['./navigation.component.scss'],
})
export class NavigationComponent implements OnInit {
public _pollsAvailables: Observable<Poll[]> = this.mockingService.pollsAvailables;
public devModeEnabled = !environment.production;
constructor(private mockingService: MockingService) {}
public ngOnInit(): void {}
}

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { FooterComponent } from './components/footer/footer.component';
import { HeaderComponent } from './components/header/header.component';
import { HomeComponent } from './components/home/home.component';
import { LogoComponent } from './components/logo/logo.component';
import { NavigationComponent } from './components/sibebar/navigation/navigation.component';
import { throwIfAlreadyLoaded } from './guards/module-import.guard';
import { SharedModule } from '../shared/shared.module';
@NgModule({
declarations: [FooterComponent, HeaderComponent, HomeComponent, LogoComponent, NavigationComponent],
imports: [CommonModule, FormsModule, RouterModule, TranslateModule, SharedModule],
exports: [HeaderComponent, FooterComponent, NavigationComponent, LogoComponent],
})
export class CoreModule {
constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
throwIfAlreadyLoaded(parentModule, 'CoreModule');
}
}

View File

@ -0,0 +1,5 @@
export enum Answer {
YES = 'YES',
NO = 'NO',
MAYBE = 'MAYBE',
}

View File

@ -0,0 +1,15 @@
export enum Language {
FR = 'fr',
// BR = 'br',
// CA = 'ca',
// DE = 'de',
// EL = 'el',
EN = 'en',
// ES = 'es',
// GL = 'gl',
// HU = 'hu',
// IT = 'it',
// NL = 'nl',
// OC = 'oc',
// SV = 'sv',
}

View File

@ -0,0 +1,6 @@
export enum Theme {
LIGHT = 'LIGHT',
DARK = 'DARK',
CONTRAST = 'CONTRAST',
RED = 'RED',
}

View File

@ -0,0 +1,5 @@
export enum UserRole {
ANONYMOUS = 'ANONYMOUS',
REGISTERED = 'REGISTERED',
ADMIN = 'ADMIN',
}

View File

@ -0,0 +1,5 @@
export function throwIfAlreadyLoaded(parentModule: any, moduleName: string): void {
if (parentModule) {
throw new Error(`${moduleName} has already been loaded. Import ${moduleName} in the AppModule only.`);
}
}

View File

@ -0,0 +1,39 @@
import { Answer } from '../enums/answer.enum';
import { User } from './user.model';
export class Choice {
constructor(
public label: string,
public imageUrl?: string,
public participants: Map<Answer, Set<User>> = new Map<Answer, Set<User>>([
[Answer.YES, new Set<User>()],
[Answer.NO, new Set<User>()],
[Answer.MAYBE, new Set<User>()],
]),
public counts: Map<Answer, number> = new Map<Answer, number>([
[Answer.YES, 0],
[Answer.NO, 0],
[Answer.MAYBE, 0],
])
) {}
public updateParticipation(user: User, responseType: Answer): void {
this.removeParticipant(user);
this.participants.get(responseType).add(user);
this.updateCounts();
}
public removeParticipant(user: User): void {
for (const responseType of Object.values(Answer)) {
if (this.participants.get(responseType).has(user)) {
this.participants.get(responseType).delete(user);
}
}
}
public updateCounts(): void {
for (const responseType of Object.values(Answer)) {
this.counts.set(responseType, this.participants.get(responseType).size);
}
}
}

View File

@ -0,0 +1,13 @@
export class Comment {
constructor(public author: string, public content: string, public dateCreated: Date) {}
public static sortChronologically(a: Comment, b: Comment): number {
if (a.dateCreated < b.dateCreated) {
return -1;
}
if (a.dateCreated > b.dateCreated) {
return 1;
}
return 0;
}
}

View File

@ -0,0 +1,28 @@
import { environment } from '../../../environments/environment';
import { DateService } from '../services/date.service';
export class PollConfiguration {
constructor(
public isAboutDate: boolean = false,
public isProtectedByPassword: boolean = false,
public password: string = '',
public isOwnerNotifiedByEmailOnNewVote: boolean = false,
public isOwnerNotifiedByEmailOnNewComment: boolean = false,
public isMaybeAnswerAvailable: boolean = false,
public areResultsPublic: boolean = true,
public isAllowingtoChangeOwnAnswers: boolean = true,
public whoCanChangeAnswers: string = 'everybody',
public dateCreated: Date = new Date(Date.now()),
public expiresDaysDelay: number = environment.poll.defaultConfig.expiracyInDays,
public expiracyAfterLastModificationInDays: number = environment.poll.defaultConfig
.expiracyAfterLastModificationInDays,
public expires: Date = DateService.addDaysToDate(
environment.poll.defaultConfig.expiracyInDays,
new Date(Date.now())
)
) {}
public static isArchived(configuration: PollConfiguration): boolean {
return configuration.expires ? DateService.isDateInPast(configuration.expires) : undefined;
}
}

View File

@ -0,0 +1,54 @@
import { environment } from 'src/environments/environment';
import { Choice } from './choice.model';
import { Comment } from './comment.model';
import { PollConfiguration } from './configuration.model';
import { User } from './user.model';
export class Poll {
constructor(
public owner: User = new User(),
public slug: string = 'default-slug',
public title: string = 'default title',
public description?: string,
public configuration: PollConfiguration = new PollConfiguration(),
public comments: Comment[] = [],
public choices: Choice[] = [],
public dateChoices: Choice[] = [],
public timeChoices: Choice[] = []
) {}
public getAdministrationUrl(): string {
return `${environment.api.baseHref}/administration/polls/${this.slug}`;
}
public getParticipationUrl(): string {
return `${environment.api.baseHref}/participation/polls/${this.slug}`;
}
public static adaptFromLocalJsonServer(
item: Pick<Poll, 'owner' | 'title' | 'description' | 'slug' | 'configuration' | 'comments' | 'choices'>
): Poll {
return new Poll(
new User(item.owner.pseudo, item.owner.email, undefined),
item.slug,
item.title,
item.description,
item.configuration,
item.comments
.map(
(c: Pick<Comment, 'author' | 'content' | 'dateCreated'>) =>
new Comment(c.author, c.content, new Date(c.dateCreated))
)
.sort(Comment.sortChronologically),
item.choices.map((c: Pick<Choice, 'label' | 'imageUrl' | 'participants' | 'counts'>) => {
const choice = new Choice(c.label, c.imageUrl, new Map(c.participants));
choice.participants.forEach((value, key) => {
choice.participants.set(key, new Set(value));
});
choice.updateCounts();
return choice;
})
);
}
}

View File

@ -0,0 +1,12 @@
import { UserRole } from '../enums/user-role.enum';
import { Poll } from './poll.model';
export class User {
constructor(
public pseudo: string = 'pseudo',
public email: string = 'example@example.com',
public polls: Poll[] = [],
public role?: UserRole,
public token?: string
) {}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ApiService } from './api.service';
describe('ApiService', () => {
let service: ApiService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ApiService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,287 @@
import { Injectable } from '@angular/core';
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { environment } from 'src/environments/environment';
import { Answer } from '../enums/answer.enum';
import { Poll } from '../models/poll.model';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Subscription } from 'rxjs';
import { ToastService } from './toast.service';
import { LoaderService } from './loader.service';
const apiVersion = environment.api.versionToUse;
const currentApiRoutes = environment.api.version[apiVersion];
const apiBaseHref = environment.api.version[apiVersion].baseHref;
const apiEndpoints = environment.api.endpoints;
@Injectable({
providedIn: 'root',
})
export class ApiService {
private useDevLocalServer = true;
private devLocalServerBaseHref = 'http://localhost:8000/';
private axiosInstance: AxiosInstance;
private readonly pollsEndpoint = apiEndpoints.polls.name;
private readonly answersEndpoint = apiEndpoints.polls.answers.name;
private readonly commentsEndpoint = apiEndpoints.polls.comments.name;
private readonly slugsEndpoint = apiEndpoints.polls.slugs.name;
private readonly usersEndpoint = apiEndpoints.users.name;
private readonly usersPollsEndpoint = apiEndpoints.users.polls.name;
private readonly usersPollsSendEmailEndpoint = apiEndpoints.users.polls.sendEmail.name;
private static loader: LoaderService;
constructor(private http: HttpClient, private loader: LoaderService, private toastService: ToastService) {
this.axiosInstance = axios.create({ baseURL: apiBaseHref });
this.axiosInstance.defaults.timeout = 2500;
this.axiosInstance.defaults.headers.post['Content-Type'] = 'application/json';
this.axiosInstance.defaults.headers.post['Accept'] = 'application/json';
this.axiosInstance.defaults.headers.post['Charset'] = 'UTF-8';
this.axiosInstance.defaults.headers.post['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS';
this.axiosInstance.defaults.headers.post['Access-Control-Allow-Origin'] = '*';
console.log('this.axiosInstance.defaults.headers', this.axiosInstance.defaults.headers);
}
//////////////////////
// CREATE OR UPDATE //
//////////////////////
public async createPoll(poll: Poll): Promise<Subscription> {
// this.loader.setStatus(true);
console.log('config', poll);
// const baseHref = this.useDevLocalServer ? 'http://localhost:8000' : apiBaseHref;
// return this.http
// .post(`${baseHref}${currentApiRoutes['api_new_poll']}`, poll, ApiService.makeHeaders())
// .subscribe(
// (res: Observable<any>) => {
// // redirect to the page to administrate the new poll
// this.toastService.display('Sondage Créé');
//
// console.log('res', res);
// // this.updateCurrentPollFromResponse(res);
//
// this.loader.setStatus(false);
// },
// (e) => {
// ApiService.handleError(e);
// }
// );
try {
console.log('currentApiRoutes', currentApiRoutes);
return await this.axiosInstance.post(`${apiBaseHref}${currentApiRoutes['api_new_poll']}`, {
data: poll,
});
} catch (error) {
ApiService.handleError(error);
}
}
public async createParticipation(
pollId: string,
choiceLabel: string,
pseudo: string,
response: Answer
): Promise<string> {
try {
return await this.axiosInstance.post(`${this.pollsEndpoint}/${pollId}${this.answersEndpoint}`, {
choiceLabel,
pseudo,
response,
});
} catch (error) {
ApiService.handleError(error);
}
}
public async createComment(slug: string, comment: string): Promise<string> {
try {
return await this.axiosInstance.post(`${this.pollsEndpoint}/${slug}${this.commentsEndpoint}`, comment);
} catch (error) {
ApiService.handleError(error);
}
}
//////////
// READ //
//////////
public async getAllAvailablePolls(): Promise<Poll[]> {
// TODO: used for facilities in DEV, should be removed in production
try {
const response: AxiosResponse<Poll[]> = await this.axiosInstance.get<Poll[]>(`${this.pollsEndpoint}`);
return response?.data;
} catch (error) {
ApiService.handleError(error);
}
}
public async getPollBySlug(slug: string): Promise<Poll | undefined> {
// TODO: identifier should be decided according to backend : Id || Slug ?
try {
// TODO: this interceptor should be avoided if backends returns the good object
const adapterInterceptor: number = this.axiosInstance.interceptors.response.use(
(response: AxiosResponse): AxiosResponse => {
if (response.data['poll']) {
// response from cipherbliss backend, actually used by oldstuffModule
response.data = response.data['poll'];
} else if (response.data[0] && response.data[0]['slug'] && response.data[0]['question']) {
// response from local json-server
response.data = Poll.adaptFromLocalJsonServer(response.data[0]);
}
return response;
}
);
const response: AxiosResponse<Poll> = await this.axiosInstance.get<Poll>(`${this.pollsEndpoint}/${slug}`);
console.log('fetch API : asking for poll with slug=' + slug, { response });
axios.interceptors.request.eject(adapterInterceptor);
return response && response.data && !Array.isArray(response.data) ? response.data : undefined;
} catch (error) {
if (error.response?.status === 404) {
return undefined;
} else {
ApiService.handleError(error);
}
}
}
public async getSlug(slug: string): Promise<boolean> {
try {
// TODO: scenario should be : if we can get this slug, it exists. if not, it doesn't. It's just a GET.
const response: AxiosResponse = await this.axiosInstance.get(
`${this.pollsEndpoint}${this.slugsEndpoint}/${slug}`
);
if (response?.status !== 404) {
return false;
}
} catch (error) {
if (error.response?.status === 404) {
return true;
} else {
ApiService.handleError(error);
}
}
}
public async sendEmailToUserOfItsPollsList(email: string): Promise<void> {
// If user is not authenticated: the list of polls is send to user's email by the backend.
try {
await this.axiosInstance.get<Poll[]>(
`${this.usersEndpoint}/${email}${this.usersPollsEndpoint}${this.usersPollsSendEmailEndpoint}`
);
} catch (error) {
ApiService.handleError(error);
}
}
public async getPollsUrlsByUserEmail(email: string): Promise<Poll[]> {
// If user is authenticated : retrieve polls & display directly in frontend.
// TODO: Backend should handle this case. Actually the endpoint doesn't exist in backend.
// Here, only the list of slugs is usefull. Maybe just handle the list of slugs.
try {
const response: AxiosResponse<Poll[]> = await this.axiosInstance.get<Poll[]>(
`${this.usersEndpoint}/${email}${this.usersPollsEndpoint}`
);
return response?.data;
} catch (error) {
ApiService.handleError(error);
}
}
////////////
// UPDATE //
////////////
public async updateAnswer(slug: string, choiceLabel: string, pseudo: string, answer: Answer): Promise<string> {
try {
return await this.axiosInstance.patch(`${this.pollsEndpoint}/${slug}${this.answersEndpoint}`, {
choiceLabel,
pseudo,
answer,
});
} catch (error) {
ApiService.handleError(error);
}
}
////////////
// DELETE //
////////////
public async deletePoll(slug: string): Promise<boolean> {
try {
const response: AxiosResponse = await this.axiosInstance.delete(`${this.pollsEndpoint}/${slug}`);
return response?.status === 204;
} catch (error) {
ApiService.handleError(error);
}
}
public async deletePollAnswers(slug: string): Promise<boolean> {
try {
const response: AxiosResponse = await this.axiosInstance.delete(
`${this.pollsEndpoint}/${slug}${this.answersEndpoint}`
);
return response?.status === 204;
} catch (error) {
ApiService.handleError(error);
}
}
public async deletePollComments(slug: string): Promise<boolean> {
try {
const response: AxiosResponse = await this.axiosInstance.delete(
`${this.pollsEndpoint}/${slug}${this.commentsEndpoint}`
);
return response?.status === 204;
} catch (error) {
ApiService.handleError(error);
}
}
/////////////////////
// PRIVATE METHODS //
/////////////////////
/**
* prepare headers like the charset and json type for any call to the backend
* @param bodyContent?
*/
static makeHeaders(bodyContent?: any) {
const headerDict = {
Charset: 'UTF-8',
'Content-Type': 'application/json',
Accept: 'application/json',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Accept,Accept-Language,Content-Language,Content-Type',
'Access-Control-Allow-Origin': '*',
};
const requestOptions = {
headers: new HttpHeaders(headerDict),
body: bodyContent,
};
return requestOptions;
}
private static handleError(error): void {
// this.loader.setStatus(true);
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.log(error.response.data);
console.log(error.response.status);
console.log(error.response.headers);
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
console.log(error.request);
} else {
// Something happened in setting up the request that triggered an Error
console.log('Error', error.message);
}
console.log(error.config);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { DateService } from './date.service';
describe('DateUtilsService', () => {
let service: DateService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(DateService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import * as moment from 'moment';
@Injectable({
providedIn: 'root',
})
export class DateService {
public static addDaysToDate(days: number, date: Date): Date {
return moment(date).add(days, 'days').toDate();
}
public static diffInDays(dateLeft: Date, dateRight: Date): number {
return moment(dateLeft).diff(moment(dateRight));
}
public static isDateInFuture(date: Date): boolean {
return this.diffInDays(date, new Date()) > 0;
}
public static isDateInPast(date: Date): boolean {
return this.diffInDays(date, new Date()) < 0;
}
public static formatDate(date: Date): string {
return moment(date).format('yyyy-MM-dd');
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LanguageService } from './language.service';
describe('LanguageService', () => {
let service: LanguageService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LanguageService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,68 @@
import { Injectable } from '@angular/core';
import { TranslateService, LangChangeEvent } from '@ngx-translate/core';
import { Language } from '../enums/language.enum';
import { StorageService } from './storage.service';
@Injectable({
providedIn: 'root',
})
export class LanguageService {
constructor(private translate: TranslateService, private storageService: StorageService) {}
public getLangage(): Language {
return this.translate.currentLang as Language;
}
public setLanguage(language: Language): void {
this.translate.use(language.toString());
}
public getAvailableLanguages(): string[] {
return this.translate.getLangs();
}
public configureAndInitTranslations(): void {
// always save in storage the currentLang used
this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.storageService.language = event.lang as Language;
});
// set all languages available
this.translate.addLangs(Object.keys(Language));
// set language
this.setLanguageOnInit();
}
private setLanguageOnInit(): void {
// set language from storage
if (!this.translate.currentLang) {
this.setLanguageFromStorage();
}
// or set language from browser
if (!this.translate.currentLang) {
this.setLanguageFromBrowser();
}
// set default language
if (!this.translate.currentLang) {
this.setLanguage(Language.FR);
}
}
private setLanguageFromStorage(): void {
console.log('this.storageService.language', this.storageService.language);
if (this.storageService.language && this.translate.getLangs().includes(this.storageService.language)) {
this.setLanguage(this.storageService.language);
}
}
private setLanguageFromBrowser(): void {
const currentBrowserLanguage: Language = this.translate.getBrowserLang().toUpperCase() as Language;
console.log('currentBrowserLanguage', currentBrowserLanguage);
if (this.translate.getLangs().includes(currentBrowserLanguage)) {
this.setLanguage(currentBrowserLanguage);
}
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LoaderService } from './loader.service';
describe('LoaderService', () => {
let service: LoaderService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LoaderService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class LoaderService {
private _loadingStatus: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
public readonly isLoading: Observable<boolean> = this._loadingStatus.asObservable();
public setStatus(status: boolean): void {
this._loadingStatus.next(status);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { MockingService } from './mocking.service';
describe('MockingService', () => {
let service: MockingService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(MockingService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,55 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { UserRole } from '../enums/user-role.enum';
import { Choice } from '../models/choice.model';
import { Poll } from '../models/poll.model';
import { User } from '../models/user.model';
import { ApiService } from './api.service';
import { UserService } from './user.service';
import { UuidService } from './uuid.service';
@Injectable({
providedIn: 'root',
})
export class MockingService {
private _pollsAvailables: BehaviorSubject<Poll[]> = new BehaviorSubject<Poll[]>([]);
public readonly pollsAvailables: Observable<Poll[]> = this._pollsAvailables.asObservable();
constructor(private apiService: ApiService, private userService: UserService, private uuidService: UuidService) {}
public async init(): Promise<void> {
const pollsAvailable = await this.apiService.getAllAvailablePolls();
this._pollsAvailables.next(pollsAvailable);
if (this._pollsAvailables.getValue() && this._pollsAvailables.getValue().length > 0) {
// arbitrary choose first owner available
const currentUser = this._pollsAvailables.getValue()[0].owner;
currentUser.polls = [this._pollsAvailables.getValue()[0]];
this.userService.updateUser(currentUser);
} else {
this.loadMock();
}
}
public loadMock(): void {
const owner = new User('TOTO', 'toto@gafam.com', [], UserRole.REGISTERED);
const poll1: Poll = new Poll(owner, this.uuidService.getUUID(), 'Quand le picnic ?', 'Pour faire la teuf');
const poll2: Poll = new Poll(
owner,
this.uuidService.getUUID(),
'On fait quoi à la soirée ?',
'Balancez vos idées'
);
poll1.choices = [new Choice('mardi prochain'), new Choice('mercredi')];
poll2.choices = [new Choice('jeux'), new Choice('danser'), new Choice('discuter en picolant')];
this._pollsAvailables.next([poll1, poll2]);
owner.polls = [poll1, poll2];
this.userService.updateUser(owner);
console.info('MOCKING user', { user: owner });
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ModalService } from './modal.service';
describe('ModalService', () => {
let service: ModalService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ModalService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,20 @@
import { ComponentType } from '@angular/cdk/portal';
import { Injectable, TemplateRef } from '@angular/core';
import { MatDialog, MatDialogConfig } from '@angular/material/dialog';
@Injectable({
providedIn: 'root',
})
export class ModalService {
constructor(public dialog: MatDialog) {}
public openModal_OLD<T, K>(componentOrTemplateRef: ComponentType<T> | TemplateRef<T>, data?: K): void {
this.dialog.open(componentOrTemplateRef, { data: data });
}
public openModal<T, D = any>(
componentOrTemplateRef: ComponentType<T> | TemplateRef<T>,
config?: MatDialogConfig<D>
): void {
this.dialog.open<T, D>(componentOrTemplateRef, config);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { PollService } from './poll.service';
describe('PollService', () => {
let service: PollService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(PollService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,243 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { Answer } from '../enums/answer.enum';
import { Choice } from '../models/choice.model';
import { Poll } from '../models/poll.model';
import { User } from '../models/user.model';
import { ApiService } from './api.service';
import { ToastService } from './toast.service';
import { UserService } from './user.service';
import { UuidService } from './uuid.service';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class PollService implements Resolve<Poll> {
private _poll: BehaviorSubject<Poll | undefined> = new BehaviorSubject<Poll | undefined>(undefined);
public readonly poll: Observable<Poll | undefined> = this._poll.asObservable();
constructor(
private http: HttpClient,
private router: Router,
private apiService: ApiService,
private userService: UserService,
private uuidService: UuidService,
private toastService: ToastService
) {}
/**
* auto fetch a poll when route is looking for one in the administration pattern
* @param route
* @param state
*/
public async resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<Poll> {
const segments: string[] = state.url.split('/');
const wantedSlug: string = segments.includes('poll') ? segments[segments.indexOf('poll') + 1] : '';
if (!wantedSlug && state.url.includes('administration')) {
// creation of new poll
const poll = new Poll(this.userService.getCurrentUser(), this.uuidService.getUUID(), '');
this._poll.next(poll);
this.router.navigate(['poll/' + poll.slug + '/administration']);
}
if (!this._poll.getValue() || !this._poll.getValue().slug || this._poll.getValue().slug !== wantedSlug) {
await this.loadPollBySlug(wantedSlug);
}
if (this._poll.getValue()) {
return this._poll.getValue();
} else {
this.router.navigate(['page-not-found']);
return;
}
}
getAllAvailablePolls() {
const baseHref = environment.api.version.apiV1.baseHref;
console.log('getAllAvailablePolls baseHref', baseHref);
const headers = ApiService.makeHeaders();
console.log('getAllAvailablePolls headers', headers);
try {
this.http.get(`${baseHref}/poll`, headers).subscribe((res: Observable<any>) => {
console.log('getAllAvailablePolls res', res);
});
} catch (e) {
console.log('getAllAvailablePolls e', e);
}
}
public async loadPollBySlug(slug: string): Promise<void> {
console.log('slug', slug);
if (slug) {
const poll: Poll | undefined = await this.apiService.getPollBySlug(slug);
console.log({ loadPollBySlugResponse: poll });
this.updateCurrentPoll(poll);
}
}
public updateCurrentPoll(poll: Poll): void {
this._poll.next(poll);
}
/**
* make a uniq slug for the current poll creation
* @param config
*/
makeSlug(config: Poll): string {
let str = '';
str =
config.configuration.dateCreated.getFullYear() +
'_' +
(config.configuration.dateCreated.getMonth() + 1) +
'_' +
config.configuration.dateCreated.getDate() +
'_' +
config.owner.pseudo +
'_' +
config.title;
str = str.replace(/^\s+|\s+$/g, ''); // trim
str = str.toLowerCase();
// remove accents, swap ñ for n, etc
const from = 'àáäâèéëêìíïîòóöôùúüûñç·/_,:;';
const to = 'aaaaeeeeiiiioooouuuunc------';
for (let i = 0, l = from.length; i < l; i++) {
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i));
}
str = str
.replace(/[^a-z0-9 -]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by -
.replace(/-+/g, '-'); // collapse dashes
return str + '-' + this.uuidService.getUUID();
}
public async saveCurrentPoll(): Promise<void> {
const pollUrl: Subscription = await this.apiService.createPoll(this._poll.getValue());
// TODO: Maybe handle the url to update currentPoll according to backend response
if (pollUrl) {
console.log('pollUrl', pollUrl);
this.toastService.display('Le sondage a été enregistré.');
} else {
this.toastService.display('Le sondage na été correctement enregistré, veuillez ré-essayer.');
}
}
public saveParticipation(choice: Choice, user: User, response: Answer): void {
const currentPoll = this._poll.getValue();
currentPoll.choices.find((c) => c.label === choice.label)?.updateParticipation(user, response);
this.updateCurrentPoll(currentPoll);
this.apiService.createParticipation(currentPoll.slug, choice.label, user.pseudo, response);
this.toastService.display('Votre participation au sondage a été enregistrée.');
}
public async deleteAllAnswers(): Promise<void> {
await this.apiService.deletePollAnswers(this._poll.getValue().slug);
this.toastService.display('Les participations des votants à ce sondage ont été supprimées.');
}
public async addComment(comment: string): Promise<void> {
await this.apiService.createComment(this._poll.getValue().slug, comment);
this.toastService.display('Votre commentaire a été enregistré.');
}
public async deleteComments(): Promise<void> {
await this.apiService.deletePollComments(this._poll.getValue().slug);
this.toastService.display('Les commentaires de ce sondage ont été supprimés.');
}
public buildAnswersByChoiceLabelByPseudo(poll: Poll): Map<string, Map<string, Answer>> {
const pseudos: Set<string> = new Set();
poll.choices.forEach((choice: Choice) => {
choice.participants.forEach((users: Set<User>) => {
users.forEach((user: User) => {
pseudos.add(user.pseudo);
});
});
});
const list = new Map<string, Map<string, Answer>>();
pseudos.forEach((pseudo: string) => {
list.set(
pseudo,
new Map<string, Answer>(
poll.choices.map((choice: Choice) => {
return [choice.label, undefined];
})
)
);
});
poll.choices.forEach((choice: Choice) => {
choice.participants.forEach((users: Set<User>, answer: Answer) => {
users.forEach((user: User) => {
list.get(user.pseudo).set(choice.label, answer);
});
});
});
return list;
}
newPollFromForm(form: any): any {
const newpoll = new Poll(
this.userService.getCurrentUser(),
this.uuidService.getUUID(),
form.controls.title.value
);
/**
* convert to API version 1 config poll
*/
const apiV1Poll = {
menuVisible: true,
expiracyDateDefaultInDays: newpoll.configuration.expiresDaysDelay,
deletionDateAfterLastModification: newpoll.configuration.expiracyAfterLastModificationInDays,
pollType: newpoll.configuration.isAboutDate ? 'dates' : 'classic', // classic or dates
title: newpoll.title,
description: newpoll.description,
myName: newpoll.owner.pseudo,
myComment: '',
isAdmin: true, // when we create a poll, we are admin on it
myVoteStack: {},
myTempVoteStack: 0,
myEmail: newpoll.owner.email,
myPolls: [], // list of retrieved polls from the backend api
/*
date specific poll, we have the choice to setup different hours (timeList) for all possible dates (dateList), or use the same hours for all dates
*/
allowSeveralHours: 'true',
// access
visibility: newpoll.configuration.areResultsPublic, // visible to one with the link:
voteChoices: newpoll.configuration.isMaybeAnswerAvailable ? 'yes, maybe, no' : 'yes', // possible answers to a vote choice: only "yes", "yes, maybe, no"
creationDate: new Date(),
expirationDate: '', // expiracy date
voteStackId: null, // id of the vote stack to update
pollId: null, // id of the current poll when created. data given by the backend api
pollSlug: null, // id of the current poll when created. data given by the backend api
currentPoll: null, // current poll selected with createPoll or getPoll of ConfigService
passwordAccess: newpoll.configuration.isProtectedByPassword,
password: newpoll.configuration.password,
customUrl: newpoll.slug, // custom slug in the url, must be unique
customUrlIsUnique: null, // given by the backend
urlSlugPublic: null,
urlPublic: null,
urlAdmin: null,
adminKey: '', // key to change config of the poll
owner_modifier_token: '', // key to change a vote stack
canModifyAnswers: newpoll.configuration.isAllowingtoChangeOwnAnswers, // bool for the frontend selector
whoModifiesAnswers: newpoll.configuration.whoCanChangeAnswers, // everybody, self, nobody (: just admin)
whoCanChangeAnswers: newpoll.configuration.whoCanChangeAnswers, // everybody, self, nobody (: just admin)
dateList: newpoll.dateChoices, // sets of days as strings, config to set identical time for days in a special days poll
timeList: newpoll.timeChoices, // ranges of time expressed as strings
answers: newpoll.choices,
// modals
displayConfirmVoteModalAdmin: false,
};
console.log('apiV1Poll', apiV1Poll);
return apiV1Poll;
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { StorageService } from './storage.service';
describe('StorageService', () => {
let service: StorageService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(StorageService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { LocalStorage } from 'ngx-webstorage';
import { Language } from '../enums/language.enum';
import { Theme } from '../enums/theme.enum';
@Injectable({
providedIn: 'root',
})
export class StorageService {
@LocalStorage()
public theme: Theme;
@LocalStorage()
public language: Language;
@LocalStorage()
public userPollsIds: string[];
@LocalStorage()
public pseudo: string;
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';
describe('ThemeService', () => {
let service: ThemeService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ThemeService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { Theme } from '../enums/theme.enum';
@Injectable({
providedIn: 'root',
})
export class ThemeService {
private _theme: BehaviorSubject<Theme> = new BehaviorSubject<Theme>(Theme.LIGHT);
public readonly theme: Observable<Theme> = this._theme.asObservable();
public selectTheme(theme: Theme): void {
this._theme.next(theme);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ToastService } from './toast.service';
describe('MessageService', () => {
let service: ToastService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ToastService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
@Injectable({
providedIn: 'root',
})
export class ToastService {
constructor(private _snackBar: MatSnackBar) {}
public display(message: string, action?: string): void {
const config: MatSnackBarConfig = { duration: 2000 };
this._snackBar.open(message, action, config);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { UserRole } from '../enums/user-role.enum';
import { User } from '../models/user.model';
import { ApiService } from './api.service';
@Injectable({
providedIn: 'root',
})
export class UserService {
public anonymous: User = new User('', '', [], UserRole.ANONYMOUS);
private _user: BehaviorSubject<User> = new BehaviorSubject<User>(this.anonymous);
public readonly user: Observable<User> = this._user.asObservable();
constructor(private apiService: ApiService) {}
public updateUser(user: User): void {
this._user.next(user);
}
public getCurrentUser(): User {
return this._user.getValue();
}
public isCurrentUserIdentifiable(): boolean {
return this._user.getValue()?.pseudo ? true : false;
}
public async getUserPolls(): Promise<void> {
const currentUser: User = this._user.getValue();
currentUser.polls = await this.apiService.getPollsUrlsByUserEmail(currentUser.email);
this.updateUser(currentUser);
}
public async sendEmailToUserTheListOfItsPolls(): Promise<void> {
await this.apiService.sendEmailToUserOfItsPollsList(this._user.getValue().email);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { UuidService } from './uuid.service';
describe('UuidService', () => {
let service: UuidService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UuidService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import ShortUniqueId from 'short-unique-id';
@Injectable({
providedIn: 'root',
})
export class UuidService {
private uid = new ShortUniqueId();
public getUUID(): string {
return this.uid();
}
/**
* generate unique id to have a default url for future poll
*/
getLongUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}

View File

@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AdministrationComponent } from './administration.component';
import { NamingComponent } from './naming/naming.component';
const routes: Routes = [
{ path: '', component: AdministrationComponent },
{ path: 'naming', component: NamingComponent },
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AdministrationRoutingModule {}

View File

@ -0,0 +1,5 @@
<div class="columns administration">
<div class="column">
<app-admin-form [poll]="poll"></app-admin-form>
</div>
</div>

View File

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AdministrationComponent } from './administration.component';
describe('AdministrationComponent', () => {
let component: AdministrationComponent;
let fixture: ComponentFixture<AdministrationComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AdministrationComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AdministrationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More