Merge remote-tracking branch 'upstream/master'

This commit is contained in:
bain 2024-08-02 15:35:15 +02:00
commit dc4f6ca9e1
Signed by: bain
GPG key ID: 31F0F25E3BED0B9B
312 changed files with 62376 additions and 23704 deletions

View file

@ -4,7 +4,6 @@
root = true
[*]
end_of_line = lf
charset = utf-8

View file

@ -5,5 +5,11 @@
Setting `disableAnalytics` to true will prevent any data from being sent.
*/
"disableAnalytics": false
"disableAnalytics": false,
/**
Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript
rather than JavaScript by default, when a TypeScript version of a given blueprint is available.
*/
"isTypeScriptProject": true
}

View file

@ -1,21 +1,14 @@
# unconventional js
/blueprints/*/files/
/vendor/
# compiled output
/dist/
/tmp/
# dependencies
/bower_components/
/node_modules/
# misc
/api
/coverage/
!.*
.*/
# ember-try
/.node_modules.ember-try/
/bower.json.ember-try
/package.json.ember-try

View file

@ -1,55 +1,64 @@
'use strict';
module.exports = {
root: true,
parser: 'babel-eslint',
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
legacyDecorators: true
}
ecmaVersion: 'latest',
},
plugins: [
'ember'
],
plugins: ['ember', '@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:ember/recommended'
'plugin:ember/recommended',
'plugin:prettier/recommended',
],
env: {
browser: true,
},
rules: {
// Croodle is not compliant with some of the recommended rules yet.
// We should refactor the code step by step and enable them as soon
// as the code is compliant.
'ember/classic-decorator-no-classic-methods': 'warn',
'ember/no-controller-access-in-routes': 'warn',
'ember/no-observers': 'warn',
'ember/no-jquery': 'error',
'no-prototype-builtins': 'warn',
},
overrides: [
// ts files
{
files: ['**/*.ts'],
extends: [
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {},
},
// node files
{
files: [
'.eslintrc.js',
'.template-lintrc.js',
'ember-cli-build.js',
'testem.js',
'blueprints/*/index.js',
'config/**/*.js',
'lib/*/index.js',
'server/**/*.js'
'./.eslintrc.js',
'./.prettierrc.js',
'./.stylelintrc.js',
'./.template-lintrc.js',
'./ember-cli-build.js',
'./testem.js',
'./blueprints/*/index.js',
'./config/**/*.js',
'./lib/*/index.js',
'./server/**/*.js',
],
parserOptions: {
sourceType: 'script'
},
env: {
browser: false,
node: true
node: true,
},
plugins: ['node'],
rules: Object.assign({}, require('eslint-plugin-node').configs.recommended.rules, {
// add your custom rules and overrides for node files here
// this can be removed once the following is fixed
// https://github.com/mysticatea/eslint-plugin-node/issues/77
'node/no-unpublished-require': 'off'
})
}
]
extends: ['plugin:n/recommended'],
},
{
// test files
files: ['tests/**/*-test.{js,ts}'],
extends: ['plugin:qunit/recommended'],
rules: {},
},
],
};

156
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,156 @@
name: Test Frontend and backend
# This workflow is triggered on pushes to the repository.
on:
push:
branches:
- master
- renovate/*
pull_request:
jobs:
lint:
name: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install Dependencies
run: npm ci
- name: Lint
run: npm run lint
test-bundlesize:
name: test bundlesize
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install php
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
extensions: mbstring, zip
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install node modules
run: npm ci
- name: Run tests
run: npm run test:bundlesize
test-csp-header:
name: test CSP in .htaccess
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install node modules
run: npm ci
- name: Run tests
run: npm run test:csp-header
test-chrome:
name: test against Chrome
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install node modules
run: npm ci
- name: Install chrome browser
run: |
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo apt install ./google-chrome-stable_current_amd64.deb
- name: Build with test environment
env:
CI: true
run: npm run build --environment test
- name: run tests in chrome
run: npm run test:ember --launch Chrome --path dist
test-firefox:
name: test against Firefox
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install node modules
run: npm ci
- name: Setup firefox
uses: browser-actions/setup-firefox@latest
with:
firefox-version: 102.0.1
- name: Build with test environment
env:
CI: true
run: npm run build --environment test
- name: run tests in firefox
run: npm run test:ember --launch Firefox --path dist
test-browserstack:
name: test against additional browser in BrowserStack
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install node modules
run: npm ci
- name: Build with test environment
env:
CI: true
run: npm run build --environment test
- name: 'BrowserStack Env Setup'
uses: 'browserstack/github-actions/setup-env@master'
with:
username: 'jeldrikhanschke1'
access-key: 'xaM9Uxurv2GyxFLKQXgj'
- name: 'Start BrowserStackLocal Tunnel'
uses: 'browserstack/github-actions/setup-local@master'
with:
local-testing: 'start'
local-logging-level: 'all-logs'
local-identifier: 'random'
- name: 'Running test on BrowserStack'
run: npm run test:ember --config-file testem.browserstack.js --path dist
- name: 'BrowserStackLocal Stop'
uses: browserstack/github-actions/setup-local@master
with:
local-testing: stop
test-backend:
name: Test php backend
runs-on: ubuntu-latest
strategy:
matrix:
php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3']
steps:
- name: Checkout repository files
uses: actions/checkout@v4
- name: Install php
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
extensions: mbstring, zip
- name: Install php dependencies
run: composer install
working-directory: ./api
- name: Run backend tests
run: ./vendor/bin/codecept run
working-directory: ./api

View file

@ -1,120 +0,0 @@
name: Test Frontend and backend
# This workflow is triggered on pushes to the repository.
on:
push:
branches:
- master
pull_request:
jobs:
lint-javascript:
name: lint javascript
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: Install node modules
run: yarn install
- name: Run lint
run: yarn lint:js
lint-templates:
name: lint ember templates
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: Install node modules
run: yarn install
- name: Run lint
run: yarn lint:hbs
test-bundlesize:
name: test bundlesize
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Install php
uses: shivammathur/setup-php@v1
with:
php-version: '7.4'
extensions: mbstring, zip
- uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: Install node modules
run: yarn install
- name: Run tests
run: yarn test:bundlesize
test-csp-header:
name: test CSP in .htaccess
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: Install node modules
run: yarn install
- name: Run tests
run: yarn test:csp-header
test-chrome:
name: test in chromium
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: Install node modules
run: yarn install
- name: Install chrome browser
run: |
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo apt install ./google-chrome-stable_current_amd64.deb
- name: Build with test environment
env:
CI: true
run: yarn build --environment test
- name: run tests in chrome
run: yarn test --launch Chrome --path dist
test-firefox:
name: test in firefox
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: Install node modules
run: yarn install
- name: Install Firefox
run: sudo apt-get install firefox
- name: Build with test environment
env:
CI: true
run: yarn build --environment test
- name: run tests in firefox
run: yarn test --launch Firefox --path dist
test-backend:
name: Test php backend
runs-on: ubuntu-latest
strategy:
matrix:
php-versions: ['7.2', '7.3', '7.4']
steps:
- name: Checkout repository files
uses: actions/checkout@v2
- name: Install php
uses: shivammathur/setup-php@v1
with:
php-version: ${{ matrix.php-versions }}
extensions: mbstring, zip
- name: Install php dependencies
run: composer install
working-directory: ./api
- name: Run backend tests
run: ./vendor/bin/codecept run
working-directory: ./api

17
.gitignore vendored
View file

@ -1,33 +1,34 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist/
/tmp/
/declarations/
/api/tests/_output/
/api/tests/_tmp/
/api/tests/_support/_generated/
# dependencies
/bower_components/
/node_modules/
/api/vendor/
# misc
/.env*
/.pnp*
/.sass-cache
/connect.lock
/.eslintcache
/coverage/
/libpeerconnection.log
/npm-debug.log*
/testem.log
/yarn-error.log
# ember-try
/.node_modules.ember-try/
/bower.json.ember-try
/npm-shrinkwrap.json.ember-try
/package.json.ember-try
/package-lock.json.ember-try
/yarn.lock.ember-try
# broccoli-debug
/DEBUG/
# BrowserStack
/*.pid
/*.log

13
.prettierignore Normal file
View file

@ -0,0 +1,13 @@
# unconventional js
/blueprints/*/files/
# compiled output
/dist/
# misc
/coverage/
!.*
.*/
# ember-try
/.node_modules.ember-try/

12
.prettierrc.js Normal file
View file

@ -0,0 +1,12 @@
'use strict';
module.exports = {
overrides: [
{
files: '*.{js,ts}',
options: {
singleQuote: true,
},
},
],
};

View file

@ -14,5 +14,8 @@
},
"npm": {
"publish": false
},
"plugins": {
"@release-it-plugins/lerna-changelog": {}
}
}

View file

@ -1,37 +0,0 @@
{
"extends": [
"config:base"
],
"ignoreDeps": [
"@ember/jquery",
"@ember/optional-features",
"broccoli-asset-rev",
"ember-ajax",
"ember-cli",
"ember-cli-app-version",
"ember-cli-babel",
"ember-cli-dependency-checker",
"ember-cli-eslint",
"ember-cli-htmlbars",
"ember-cli-htmlbars-inline-precompile",
"ember-cli-inject-live-reload",
"ember-cli-sri",
"ember-cli-template-lint",
"ember-cli-uglify",
"ember-data",
"ember-export-application-global",
"ember-load-initializers",
"ember-maybe-import-regenerator",
"ember-qunit",
"ember-resolver",
"ember-source",
"ember-welcome-page",
"eslint-plugin-ember",
"loader.js",
"qunit-dom"
],
"ignorePaths": [
"lib/**"
],
"rangeStrategy": "update-lockfile"
}

8
.stylelintignore Normal file
View file

@ -0,0 +1,8 @@
# unconventional files
/blueprints/*/files/
# compiled output
/dist/
# addons
/.node_modules.ember-try/

15
.stylelintrc.js Normal file
View file

@ -0,0 +1,15 @@
'use strict';
module.exports = {
extends: [
'stylelint-config-standard',
'stylelint-config-standard-scss',
'stylelint-prettier/recommended',
],
rules: {
'declaration-block-no-duplicate-properties': null,
'no-descending-specificity': null,
'scss/no-global-function-names': null,
'selector-class-pattern': null,
},
};

View file

@ -1,10 +1,19 @@
'use strict';
module.exports = {
extends: 'recommended',
plugins: ['ember-template-lint-plugin-prettier'],
extends: ['recommended', 'ember-template-lint-plugin-prettier:recommended'],
rules: {
'no-implicit-this': {
allow: ['scroll-first-invalid-element-into-view-port'],
},
},
overrides: [
{
files: ['tests/integration/modifiers/*.js'],
rules: {
'require-input-label': false,
},
},
],
};

View file

@ -1,70 +0,0 @@
---
language: php
matrix:
include:
- env: TEST="API"
php: 7.3
- env: TEST="API"
php: 7.2
- env: TEST="API"
php: 7.1
- env: TEST="EMBER"
- env: TEST="BROWSER"
- env: TEST="BUNDLESIZE"
dist: trusty
sudo: false
addons:
chrome: stable
firefox: latest-esr
cache:
yarn: true
env:
global:
- "BROWSERSTACK_USERNAME=jeldrikhanschke1"
- "BROWSERSTACK_ACCESS_KEY=xaM9Uxurv2GyxFLKQXgj"
before_install:
# use a recent node version if ember build is tested
- if [ $TEST = "EMBER" ] || [ $TEST = "BROWSER" ] || [ $TEST = "BUNDLESIZE" ]; then nvm install --lts; fi
# provide yarn if ember build is tested
- if [ $TEST = "EMBER" ] || [ $TEST = "BROWSER" ] || [ $TEST = "BUNDLESIZE" ]; then curl -o- -L https://yarnpkg.com/install.sh | bash; fi
- if [ $TEST = "EMBER" ] || [ $TEST = "BROWSER" ] || [ $TEST = "BUNDLESIZE" ]; then export PATH=$HOME/.yarn/bin:$PATH; fi
install:
# install dependencies for client
- if [ $TEST = "EMBER" ] || [ $TEST = "BROWSER" ] || [ $TEST = "BUNDLESIZE" ]; then yarn install --no-interactive; fi
# install dependencies for api
- if [ $TEST = "API" ]; then cd api/ && composer install && cd ..; fi
before_script:
# http://php.net/manual/de/ini.core.php#ini.always-populate-raw-post-data
- if [ $TEST = "API" ]; then echo 'always_populate_raw_post_data = -1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini; fi
# create a browser stack tunnel for cross-browser testing
- if [ $TEST = "BROWSER" ]; then node_modules/ember-cli/bin/ember browserstack:connect; fi
branches:
only:
- master
script:
# run frontend and integration tests
- if [ $TEST = "EMBER" ]; then yarn run lint:hbs; fi
- if [ $TEST = "EMBER" ]; then yarn run lint:js; fi
- if [ $TEST = "EMBER" ]; then yarn test; fi
# test that CSP headers in public/.htaccess are matching the ones configured in config/environment.js
- if [ $TEST = "EMBER" ]; then grep "`node_modules/ember-cli/bin/ember csp-headers --environment production --silent 2>&1 | sed 's/ $//'`" public/.htaccess || (echo "CSP headers in public/.htaccess does not match configuration" && exit 1); fi
# test against different browsers using sauce lab
- if [ $TEST = "BROWSER" ]; then yarn test --config-file testem.browserstack.js; fi
# test bundle size
- if [ $TEST = "BUNDLESIZE" ]; then yarn test:bundlesize; fi
# run api tests with composer
- if [ $TEST = "API" ]; then cd api/ && ./vendor/bin/codecept run && cd ..; fi
after_script:
# destroy the sauce tunnel
- if [ $TEST = "BROWSER" ]; then node_modules/ember-cli/bin/ember browserstack:disconnect; fi

View file

@ -1,3 +1,3 @@
{
"ignore_dirs": ["tmp", "dist"]
"ignore_dirs": ["dist"]
}

View file

@ -1,8 +1,7 @@
# Croodle
[![Build Status](https://travis-ci.org/jelhan/croodle.svg?branch=master)](https://travis-ci.org/jelhan/croodle)
[![Build Status](https://github.com/jelhan/croodle/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/jelhan/croodle/actions/workflows/ci.yml?query=branch%3Amaster)
[![Code Climate](https://codeclimate.com/github/jelhan/croodle/badges/gpa.svg)](https://codeclimate.com/github/jelhan/croodle)
[![devDependency Status](https://david-dm.org/jelhan/croodle/dev-status.svg)](https://david-dm.org/jelhan/croodle?type=dev)
Croodle is an end-to-end encrypted web application to schedule a date or to do a poll on a any topic. All data like title, description, number and labels of options, available answers and names of users and their selections are encrypted/decrypted in the browser using strong 256-bit AES encryption.
@ -23,7 +22,7 @@ Theoretically you could also check for an attack like this by analysing the sour
## Requirements
Croodle is designed to have as few as possible requirements on the server it is running on. Croodle runs on almost every web space with PHP >= 5.6. Croodle stores the data in textfiles, so there is no need for a database server like MySQL.
Croodle is designed to have as few as possible requirements on the server it is running on. Croodle runs on almost every web space with PHP >= 7.2. Croodle stores the data in textfiles, so there is no need for a database server like MySQL.
Due to security reasons you should have TLS encryption enabled and provide a valid certificate. (see the [security notice](#security-notice))
@ -31,14 +30,14 @@ Due to security reasons you should have TLS encryption enabled and provide a val
Production builds are provided as github [release assets](https://github.com/jelhan/croodle/releases).
If you like to build yourself you have to install [yarn](https://yarnpkg.com/), [ember-cli](http://www.ember-cli.com/) and [composer](https://getcomposer.org/) before.
If you like to build yourself you have to install [node](https://nodejs.org/), [ember-cli](http://www.ember-cli.com/) and [composer](https://getcomposer.org/) before. It's recommended using [volta](https://volta.sh/) to ensure a compatible and tested node version is used.
```shell
git clone git@github.com:jelhan/croodle.git
cd croodle
yarn install
npm install
cd api/ && composer install --no-dev && cd ..
yarn build --prod
npm run build
```
Afterwards copy all files in `/dist` folder to your werbserver.
@ -65,15 +64,26 @@ If source files are changing, a rebuild and reload is triggered.
By default Croodle uses an api mock in development. Since that one
does not persist records all polls are gone after a reload.
If you like to test against the real API, run api via php built-in web
server: `php -S 127.0.0.1:8080 -t dist/`
If you like to test against the real API, you should run the API
using php built-in web server locally:
```sh
php -S 127.0.0.1:8080 -t dist/
```
Afterwards start ember-cli development server using `--proxy` option:
`ember server --proxy http://127.0.0.1:8080`.
```sh
ember server --proxy http://127.0.0.1:8080
```
Ember-cli clears dist folder on each rebuild. If you like to keep
created polls over rebuild, configure api to use a non default folder
to save your polls:
`CROODLE__DATA_DIR=/tmp/croodle_data php -S 127.0.0.1:8080 -t dist/`
```sh
CROODLE__DATA_DIR=/tmp/croodle_data php -S 127.0.0.1:8080 -t dist/
```
## Running tests
@ -96,9 +106,6 @@ without `--no-dev` option).
## Credits
Continous Integration powered by<br>
<a href="https://travis-ci.com/"><img src="https://travis-ci.com/images/logos/TravisCI-Full-Color.png" height="50"></a>
Cross-browser testing provided by<br>
<a href="https://www.browserstack.com"><img src="docs/Browserstack-logo.svg" height="50"></a>

View file

@ -32,14 +32,14 @@ Once the prep work is completed, the actual release is straight forward:
* First ensure that you have installed the project dependencies:
```sh
yarn install
npm ci
```
* Second, do your release:
```sh
export GITHUB_AUTH="github-personal-access-token"
yarn release
npm run release
```
[release-it](https://github.com/release-it/release-it/) manages the actual

View file

@ -177,11 +177,6 @@ class Model {
}
$data = self::convertFromStorage($storageObject);
if(method_exists($model, 'restoreLegacySupportHook')) {
$model->restoreLegacySupportHook($data);
}
$properties = array_merge(
static::ENCRYPTED_PROPERTIES,
static::PLAIN_PROPERTIES,

View file

@ -6,7 +6,6 @@ require_once 'user.php';
class Poll extends model {
const ENCRYPTED_PROPERTIES = [
'anonymousUser',
'answers',
'answerType',
'creationDate',
'description',
@ -126,20 +125,4 @@ class Poll extends model {
return false;
}
}
protected function restoreLegacySupportHook(&$data) {
if (!isset($data->version) || $data->version === 'v0.3-0') {
if (isset($data->poll) && is_object($data->poll)) {
$data = $data->poll;
}
foreach($data as $key => $value) {
if (strpos($key, 'encrypted') === 0) {
$newKey = lcfirst(substr($key, 9));
$data->$newKey = $data->$key;
unset($data->$key);
}
}
}
}
}

View file

@ -9,7 +9,7 @@ class User extends Model {
'name',
'selections'
];
const PLAIN_PROPERTIES = [
'poll',
'version'
@ -17,15 +17,15 @@ class User extends Model {
protected function generateNewId() {
$userDir = $this->getDir();
// check if user folder exists
if (!file_exists($userDir)) {
return $this->get('poll') . '_0';
}
// get all files in user folder
$files = scandir($userDir);
// get highest existing id
$highestId = 0;
foreach ($files as $f) {
@ -50,9 +50,9 @@ class User extends Model {
}
if (!Poll::isValidId($pollId)) {
throw new Exception('cound not get a valid id when getPollDir was called');
throw new Exception('Could not get a valid id when getPollDir was called');
}
return DATA_FOLDER . $pollId . '/';
}
@ -69,25 +69,9 @@ class User extends Model {
public static function isValidId($id) {
$parts = explode('_', $id);
return count($parts) === 2 &&
Poll::isValidId($parts[0]) &&
intval($parts[1]) == $parts[1];
}
protected function restoreLegacySupportHook(&$data) {
if (!isset($data->version) || $data->version === 'v0.3-0') {
if (isset($data->user) && is_object($data->user)) {
$data = $data->user;
}
foreach($data as $key => $value) {
if (strpos($key, 'encrypted') === 0) {
$newKey = lcfirst(substr($key, 9));
$data->$newKey = $data->$key;
unset($data->$key);
}
}
}
}
}

View file

@ -1,25 +1,25 @@
actor: Tester
paths:
tests: tests
log: tests/_output
data: tests/_data
support: tests/_support
envs: tests/_envs
tests: tests
log: tests/_output
data: tests/_data
support: tests/_support
envs: tests/_envs
settings:
bootstrap: _bootstrap.php
colors: true
memory_limit: 1024M
bootstrap: _bootstrap.php
colors: true
memory_limit: 1024M
error_level: E_ALL & ~E_DEPRECATED
extensions:
enabled:
- Codeception\Extension\RunFailed
- Codeception\Extension\PhpBuiltinServer
- CleanUpExtension
config:
Codeception\Extension\PhpBuiltinServer:
hostname: localhost
port: 8000
documentRoot: .
startDelay: 1
phpIni: /etc/php5/apache2/php.ini
enabled:
- Codeception\Extension\RunFailed
- Codeception\Extension\PhpBuiltinServer
- CleanUpExtension
config:
Codeception\Extension\PhpBuiltinServer:
hostname: localhost
port: 8000
documentRoot: .
startDelay: 1
params:
- .env.testing
- .env.testing

View file

@ -1,17 +1,24 @@
{
"require": {
"php": "^7.1",
"slim/slim": "^3.5"
"php": "^7.2",
"slim/slim": "3.12.4"
},
"require-dev": {
"flow/jsonpath": "^0.5.0",
"vlucas/phpdotenv": "^3.6.0",
"codeception/codeception": "^3.1",
"codeception/phpbuiltinserver": "^1.5"
"flow/jsonpath": "0.5.0",
"vlucas/phpdotenv": "3.6.10",
"codeception/codeception": "3.1.3",
"codeception/phpbuiltinserver": "@dev"
},
"config": {
"platform": {
"php": "7.1"
"php": "7.2"
}
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/jelhan/PhpBuiltinServer.git",
"reference":"support-codeception-error-level-and-memory-limit-settings"
}
]
}

2601
api/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,34 @@
<?php
$pollId = substr(md5(__FILE__), 0, 10);
$pollJson = '{"anonymousUser":"{\"iv\":\"gVHZSXyMm10Fn+kDooa7uw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"GJsQQYA7TdAa+v3Rvg==\"}","answers":"{\"iv\":\"aK1JcI3viLPIlOO45K+ePA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"Bx4SRcww+hJ46NIiVcWBUZHADADX\/XPsxXMx4XzMQZWqu6M0690D4oTflSRJoqxe0egxdfMOUxuWhmACG\/UYXSYJQjcSg+QTq6KJbaXG+SvsCMZ7iz12a\/uf9lXyiag4IbLldgL4vE3LfZO6oih\/o\/yG4hechjNdSkqUa2IvsRbXWB2aHen6a5Ch5WjqWrr4xRRrukPvf7aumilT2Cf0LswHJ2fwYNilylV0h9oegKYp+qWphm4SL8x2ogRemSCt7u7ByEOwZV0w6D9bz9RvGLTRRLJaLIm\/VlE3k7R6Hz1vyps=\"}","answerType":"{\"iv\":\"ILkAzgUfAGNUtLr7CbEJEQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"nMOp+QApQGgP9dwefNpi\"}","creationDate":"{\"iv\":\"6tWbieK03uXUR+E0AMbs0A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"YkkLVBkFyx4xFldZ7qnDESG0teHJmXaPMUB05p9L0xUIMg==\"}","description":"{\"iv\":\"fWvHh47So4WBNfEHXrwLiA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"5W7nauOakSoFD52V\"}","expirationDate":"{\"iv\":\"HRsMvEQaoCp8QdqBGHevnA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"LXYamNRDyhIY5xY+CLqI4GHbocc9NoHQtePKU9fHpJn9zg==\"}","forceAnswer":"{\"iv\":\"bh4iZ4pKe0GnXcM764702g==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"q5VBynWGotXRrc2P\"}","isDateTime":"{\"iv\":\"mlDCtvsJZaDlZD9kqfJHuA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"53g42C6Am+0s25\/DsA==\"}","options":"{\"iv\":\"ZneP\/x45NGh\/DC26GI4kvg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"4MvV9SNQq2dB6b\/MdX47R0KaRSfyZOZMEVUFDv7G3\/EcDBv7Z0pgSU9JXoF8BoSOz40rYrRtTw==\"}","pollType":"{\"iv\":\"j3P6eN0ZmNMMxLTAVD6gjQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"opwiZHAQi+I8R5HDxLfLK59DcQ==\"}","timezone":"{\"iv\":\"HKkSqcJONggGT9QQ+jZdUg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"BANN8sJlk8JK9A==\"}","title":"{\"iv\":\"4DX7dAJt7JIBHaR1V0Ct8A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"f1VUZf69nB94TF3\/HA==\"}","version":"v0.3.0+0ae62f31","serverExpirationDate":"2015-11-22T20:35:03.764Z"}';
$userJson = '{"user":{"name":"{\"iv\":\"kizIqK7FPNmRuQB7VHsMOw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"UsYMzrww3HKR8vl2TKVE\"}","selections":"{\"iv\":\"hRmiZagEhQVhw2cg6UJNrg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"2zIPGpiSC6wJHRoAMYBFPXx3qmlZg0Z/Jt/15mY+sHPLCqoAn97TKGN6KIvl/5gmgCFqLQFNo6uppCTUhljoV5y2kMtGvm0g3+NdpcejWGOeMACDPcp1mpXII87ZTfC6WrtxcWCB6UGYN8EynOdndFTGp+WVZnXCCya7YPThk/QRwoHoPWS6+TJFT9WeHV4i4kUIg2K3kdz3Op7S/c7l7KbOc8GsyjZzv0bRDnAm68/+FlJyZnvfMfU8vTxExsIsd0pBy4JBV4hg9SlCPectb5BAvBCULLDPA08prf262RUmVKJ+M3P1+5KkBQcnQwnUW/fzAQ7lqA==\"}","creationDate":"{\"iv\":\"xqdDY/A7MHLeAsoU9S/j+A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"TQOhbjveZbvdiyYpxfwNyu5pi1PLia9FApJJRmr3QoyrWA==\"}","version":"v0.3.0+0ae62f31","poll":"' . $pollId . '"}}';
$pollJson = <<<EOD
{
"anonymousUser": "{\"iv\":\"gVHZSXyMm10Fn+kDooa7uw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"GJsQQYA7TdAa+v3Rvg==\"}",
"answerType": "{\"iv\":\"ILkAzgUfAGNUtLr7CbEJEQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"nMOp+QApQGgP9dwefNpi\"}",
"creationDate": "{\"iv\":\"6tWbieK03uXUR+E0AMbs0A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"YkkLVBkFyx4xFldZ7qnDESG0teHJmXaPMUB05p9L0xUIMg==\"}",
"description": "{\"iv\":\"fWvHh47So4WBNfEHXrwLiA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"5W7nauOakSoFD52V\"}",
"expirationDate": "{\"iv\":\"HRsMvEQaoCp8QdqBGHevnA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"LXYamNRDyhIY5xY+CLqI4GHbocc9NoHQtePKU9fHpJn9zg==\"}",
"forceAnswer": "{\"iv\":\"bh4iZ4pKe0GnXcM764702g==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"q5VBynWGotXRrc2P\"}",
"isDateTime": "{\"iv\":\"mlDCtvsJZaDlZD9kqfJHuA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"53g42C6Am+0s25/DsA==\"}",
"options": "{\"iv\":\"ZneP/x45NGh/DC26GI4kvg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"4MvV9SNQq2dB6b/MdX47R0KaRSfyZOZMEVUFDv7G3/EcDBv7Z0pgSU9JXoF8BoSOz40rYrRtTw==\"}",
"pollType": "{\"iv\":\"j3P6eN0ZmNMMxLTAVD6gjQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"opwiZHAQi+I8R5HDxLfLK59DcQ==\"}",
"timezone": "{\"iv\":\"HKkSqcJONggGT9QQ+jZdUg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"BANN8sJlk8JK9A==\"}",
"title": "{\"iv\":\"4DX7dAJt7JIBHaR1V0Ct8A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"f1VUZf69nB94TF3/HA==\"}",
"version": "v0.3.0+0ae62f31",
"serverExpirationDate": "2015-11-22T20:35:03.764Z"
}
EOD;
$userJson = <<<EOD
{
"user": {
"name": "{\"iv\":\"kizIqK7FPNmRuQB7VHsMOw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"UsYMzrww3HKR8vl2TKVE\"}",
"selections": "{\"iv\":\"hRmiZagEhQVhw2cg6UJNrg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"2zIPGpiSC6wJHRoAMYBFPXx3qmlZg0Z/Jt/15mY+sHPLCqoAn97TKGN6KIvl/5gmgCFqLQFNo6uppCTUhljoV5y2kMtGvm0g3+NdpcejWGOeMACDPcp1mpXII87ZTfC6WrtxcWCB6UGYN8EynOdndFTGp+WVZnXCCya7YPThk/QRwoHoPWS6+TJFT9WeHV4i4kUIg2K3kdz3Op7S/c7l7KbOc8GsyjZzv0bRDnAm68/+FlJyZnvfMfU8vTxExsIsd0pBy4JBV4hg9SlCPectb5BAvBCULLDPA08prf262RUmVKJ+M3P1+5KkBQcnQwnUW/fzAQ7lqA==\"}",
"creationDate": "{\"iv\":\"xqdDY/A7MHLeAsoU9S/j+A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"TQOhbjveZbvdiyYpxfwNyu5pi1PLia9FApJJRmr3QoyrWA==\"}",
"version": "v0.3.0+0ae62f31",
"poll": "$pollId"
}
}
EOD;
$pollDir = 'tests/_tmp/data/' . $pollId . '/';
$userDir = $pollDir . 'user/';

View file

@ -1,6 +1,23 @@
<?php
$pollJson = '{"poll":{"title":"{\"iv\":\"szAOrvhM+bODnldJJP0pGw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"KwMkE7bneP0MX6hQEnM=\"}","description":"{\"iv\":\"aohDHKaO7c7Fl5vIueBkcA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"+ygmsnYAsEBLZRUV\"}","pollType":"{\"iv\":\"suOomfYe6kKBxjln091tCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"7iDQ2y571OBiJNxdaUY0PjqlgQ==\"}","answerType":"{\"iv\":\"z1V+GmSWJxSng0bXxnYNRA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ZDf5sBxR6rO+DdO/yFmk\"}","answers":"{\"iv\":\"WRdAwEa0DF+E83ginLYtPw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"Oaer31ct2PXkmXkzJ1EXRPM3LMf6vGfzMZqjODwey4f7EhqSCUhYov+N7AZKCAAXYVS4WR84kKizxXBK2PQBSFrlB3Bll74ED9ZzRJSJD00otMG9BbgUR90aFws+1jMBP5vpti9+POsii85zLbDPkNg/Th/C4Ufv5YWwg/4ZV0bFMyOgfdjtOWaG5YAMTGUIkz9U9+VCesYJQaTb497qTD/Wmtz8J/2pUxdL5/b5xkdh2DJ4/N5q0Kz/CEbaoKwbexnQDlSr3ldlIhs7UmBjC9gkpgG2l9fu6a0VZFBE8hvzYrw=\"}","options":"{\"iv\":\"79HYzanMnjtgvBMowUWHaA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"HuFz0AFCpupdmXYdCcAX4OiwpMs/Jm5XK/thQW0phxKd0OxKt9NZ3FE/rMAiYVqRKBqFp+KLhBnbs9ewTFW0Xrvw6paTnvpY9Ftcz1MB\"}","creationDate":"{\"iv\":\"DBKid4Yiyr61GVLigJj20w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ORRPzySTa6vt7GQrJOGBvNZXXq4p/ANinfanE/51DbcDNw==\"}","forceAnswer":"{\"iv\":\"P5Dg5Y9fS7EFxvqzP8u20A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"90G4jQ1PbalZyyzz\"}","anonymousUser":"{\"iv\":\"SOqei2Y7QZt1PFR6IXR4qg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"WAg0oSjCiMAO+JqzIg==\"}","timezone":"{\"iv\":\"l0VeY3CPUvMtoDPrw7+iCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"qBlHlZ0nLd3mqA==\"}","expirationDate":"{\"iv\":\"Y0O4n9+Tj+4LSmLoFTaNow==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"jCz8DFIS5eLI4tsjfpr+F4lG+F27BItHPdj85o5+gaDayA==\"}","serverExpirationDate":"2015-11-22T22:05:15.065Z","version":"v0.3.0+0ae62f31"}}';
$pollJson = <<<EOD
{
"poll": {
"title": "{\"iv\":\"szAOrvhM+bODnldJJP0pGw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"KwMkE7bneP0MX6hQEnM=\"}",
"description": "{\"iv\":\"aohDHKaO7c7Fl5vIueBkcA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"+ygmsnYAsEBLZRUV\"}",
"pollType": "{\"iv\":\"suOomfYe6kKBxjln091tCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"7iDQ2y571OBiJNxdaUY0PjqlgQ==\"}",
"answerType": "{\"iv\":\"z1V+GmSWJxSng0bXxnYNRA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ZDf5sBxR6rO+DdO/yFmk\"}",
"options": "{\"iv\":\"79HYzanMnjtgvBMowUWHaA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"HuFz0AFCpupdmXYdCcAX4OiwpMs/Jm5XK/thQW0phxKd0OxKt9NZ3FE/rMAiYVqRKBqFp+KLhBnbs9ewTFW0Xrvw6paTnvpY9Ftcz1MB\"}",
"creationDate": "{\"iv\":\"DBKid4Yiyr61GVLigJj20w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ORRPzySTa6vt7GQrJOGBvNZXXq4p/ANinfanE/51DbcDNw==\"}",
"forceAnswer": "{\"iv\":\"P5Dg5Y9fS7EFxvqzP8u20A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"90G4jQ1PbalZyyzz\"}",
"anonymousUser": "{\"iv\":\"SOqei2Y7QZt1PFR6IXR4qg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"WAg0oSjCiMAO+JqzIg==\"}",
"timezone": "{\"iv\":\"l0VeY3CPUvMtoDPrw7+iCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"qBlHlZ0nLd3mqA==\"}",
"expirationDate": "{\"iv\":\"Y0O4n9+Tj+4LSmLoFTaNow==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"jCz8DFIS5eLI4tsjfpr+F4lG+F27BItHPdj85o5+gaDayA==\"}",
"serverExpirationDate": "2015-11-22T22:05:15.065Z",
"version": "v0.3.0+0ae62f31"
}
}
EOD;
$I = new ApiTester($scenario);
$I->wantTo('create a poll');

View file

@ -1,8 +1,34 @@
<?php
$pollId = substr(md5(__FILE__), 0, 10);
$pollJson = '{"anonymousUser":"{\"iv\":\"SOqei2Y7QZt1PFR6IXR4qg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"WAg0oSjCiMAO+JqzIg==\"}","answers":"{\"iv\":\"WRdAwEa0DF+E83ginLYtPw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"Oaer31ct2PXkmXkzJ1EXRPM3LMf6vGfzMZqjODwey4f7EhqSCUhYov+N7AZKCAAXYVS4WR84kKizxXBK2PQBSFrlB3Bll74ED9ZzRJSJD00otMG9BbgUR90aFws+1jMBP5vpti9+POsii85zLbDPkNg\/Th\/C4Ufv5YWwg\/4ZV0bFMyOgfdjtOWaG5YAMTGUIkz9U9+VCesYJQaTb497qTD\/Wmtz8J\/2pUxdL5\/b5xkdh2DJ4\/N5q0Kz\/CEbaoKwbexnQDlSr3ldlIhs7UmBjC9gkpgG2l9fu6a0VZFBE8hvzYrw=\"}","answerType":"{\"iv\":\"z1V+GmSWJxSng0bXxnYNRA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ZDf5sBxR6rO+DdO\/yFmk\"}","creationDate":"{\"iv\":\"DBKid4Yiyr61GVLigJj20w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ORRPzySTa6vt7GQrJOGBvNZXXq4p\/ANinfanE\/51DbcDNw==\"}","description":"{\"iv\":\"aohDHKaO7c7Fl5vIueBkcA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"+ygmsnYAsEBLZRUV\"}","expirationDate":"{\"iv\":\"Y0O4n9+Tj+4LSmLoFTaNow==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"jCz8DFIS5eLI4tsjfpr+F4lG+F27BItHPdj85o5+gaDayA==\"}","forceAnswer":"{\"iv\":\"P5Dg5Y9fS7EFxvqzP8u20A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"90G4jQ1PbalZyyzz\"}","isDateTime":"{\"iv\":\"3y9OmTJDG0mLqU5zLoZwgQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"yyGaGitGrunDSpsRpw==\"}","options":"{\"iv\":\"79HYzanMnjtgvBMowUWHaA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"HuFz0AFCpupdmXYdCcAX4OiwpMs\/Jm5XK\/thQW0phxKd0OxKt9NZ3FE\/rMAiYVqRKBqFp+KLhBnbs9ewTFW0Xrvw6paTnvpY9Ftcz1MB\"}","pollType":"{\"iv\":\"suOomfYe6kKBxjln091tCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"7iDQ2y571OBiJNxdaUY0PjqlgQ==\"}","timezone":"{\"iv\":\"l0VeY3CPUvMtoDPrw7+iCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"qBlHlZ0nLd3mqA==\"}","title":"{\"iv\":\"szAOrvhM+bODnldJJP0pGw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"KwMkE7bneP0MX6hQEnM=\"}","version":"v0.3.0+0ae62f31","serverExpirationDate":"2015-11-22T22:05:15.065Z"}';
$userJson = '{"user":{"name":"{\"iv\":\"kizIqK7FPNmRuQB7VHsMOw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"UsYMzrww3HKR8vl2TKVE\"}","selections":"{\"iv\":\"hRmiZagEhQVhw2cg6UJNrg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"2zIPGpiSC6wJHRoAMYBFPXx3qmlZg0Z/Jt/15mY+sHPLCqoAn97TKGN6KIvl/5gmgCFqLQFNo6uppCTUhljoV5y2kMtGvm0g3+NdpcejWGOeMACDPcp1mpXII87ZTfC6WrtxcWCB6UGYN8EynOdndFTGp+WVZnXCCya7YPThk/QRwoHoPWS6+TJFT9WeHV4i4kUIg2K3kdz3Op7S/c7l7KbOc8GsyjZzv0bRDnAm68/+FlJyZnvfMfU8vTxExsIsd0pBy4JBV4hg9SlCPectb5BAvBCULLDPA08prf262RUmVKJ+M3P1+5KkBQcnQwnUW/fzAQ7lqA==\"}","creationDate":"{\"iv\":\"xqdDY/A7MHLeAsoU9S/j+A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"TQOhbjveZbvdiyYpxfwNyu5pi1PLia9FApJJRmr3QoyrWA==\"}","version":"v0.3.0+0ae62f31","poll":"' . $pollId . '"}}';
$pollJson = <<<EOD
{
"anonymousUser": "{\"iv\":\"SOqei2Y7QZt1PFR6IXR4qg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"WAg0oSjCiMAO+JqzIg==\"}",
"answerType": "{\"iv\":\"z1V+GmSWJxSng0bXxnYNRA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ZDf5sBxR6rO+DdO/yFmk\"}",
"creationDate": "{\"iv\":\"DBKid4Yiyr61GVLigJj20w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ORRPzySTa6vt7GQrJOGBvNZXXq4p/ANinfanE/51DbcDNw==\"}",
"description": "{\"iv\":\"aohDHKaO7c7Fl5vIueBkcA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"+ygmsnYAsEBLZRUV\"}",
"expirationDate": "{\"iv\":\"Y0O4n9+Tj+4LSmLoFTaNow==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"jCz8DFIS5eLI4tsjfpr+F4lG+F27BItHPdj85o5+gaDayA==\"}",
"forceAnswer": "{\"iv\":\"P5Dg5Y9fS7EFxvqzP8u20A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"90G4jQ1PbalZyyzz\"}",
"isDateTime": "{\"iv\":\"3y9OmTJDG0mLqU5zLoZwgQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"yyGaGitGrunDSpsRpw==\"}",
"options": "{\"iv\":\"79HYzanMnjtgvBMowUWHaA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"HuFz0AFCpupdmXYdCcAX4OiwpMs/Jm5XK/thQW0phxKd0OxKt9NZ3FE/rMAiYVqRKBqFp+KLhBnbs9ewTFW0Xrvw6paTnvpY9Ftcz1MB\"}",
"pollType": "{\"iv\":\"suOomfYe6kKBxjln091tCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"7iDQ2y571OBiJNxdaUY0PjqlgQ==\"}",
"timezone": "{\"iv\":\"l0VeY3CPUvMtoDPrw7+iCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"qBlHlZ0nLd3mqA==\"}",
"title": "{\"iv\":\"szAOrvhM+bODnldJJP0pGw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"KwMkE7bneP0MX6hQEnM=\"}",
"version": "v0.3.0+0ae62f31",
"serverExpirationDate": "2015-11-22T22:05:15.065Z"
}
EOD;
$userJson = <<<EOD
{
"user": {
"name": "{\"iv\":\"kizIqK7FPNmRuQB7VHsMOw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"UsYMzrww3HKR8vl2TKVE\"}",
"selections": "{\"iv\":\"hRmiZagEhQVhw2cg6UJNrg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"2zIPGpiSC6wJHRoAMYBFPXx3qmlZg0Z/Jt/15mY+sHPLCqoAn97TKGN6KIvl/5gmgCFqLQFNo6uppCTUhljoV5y2kMtGvm0g3+NdpcejWGOeMACDPcp1mpXII87ZTfC6WrtxcWCB6UGYN8EynOdndFTGp+WVZnXCCya7YPThk/QRwoHoPWS6+TJFT9WeHV4i4kUIg2K3kdz3Op7S/c7l7KbOc8GsyjZzv0bRDnAm68/+FlJyZnvfMfU8vTxExsIsd0pBy4JBV4hg9SlCPectb5BAvBCULLDPA08prf262RUmVKJ+M3P1+5KkBQcnQwnUW/fzAQ7lqA==\"}",
"creationDate": "{\"iv\":\"xqdDY/A7MHLeAsoU9S/j+A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"TQOhbjveZbvdiyYpxfwNyu5pi1PLia9FApJJRmr3QoyrWA==\"}",
"version": "v0.3.0+0ae62f31",
"poll": "$pollId"
}
}
EOD;
$pollDir = TEST_DATA_DIR . $pollId . '/';
$usersDir = $pollDir . 'users/';

View file

@ -1,7 +1,23 @@
<?php
$pollId = substr(md5(__FILE__), 0, 10);
$pollJson = '{"anonymousUser":"{\"iv\":\"SOqei2Y7QZt1PFR6IXR4qg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"WAg0oSjCiMAO+JqzIg==\"}","answers":"{\"iv\":\"WRdAwEa0DF+E83ginLYtPw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"Oaer31ct2PXkmXkzJ1EXRPM3LMf6vGfzMZqjODwey4f7EhqSCUhYov+N7AZKCAAXYVS4WR84kKizxXBK2PQBSFrlB3Bll74ED9ZzRJSJD00otMG9BbgUR90aFws+1jMBP5vpti9+POsii85zLbDPkNg\/Th\/C4Ufv5YWwg\/4ZV0bFMyOgfdjtOWaG5YAMTGUIkz9U9+VCesYJQaTb497qTD\/Wmtz8J\/2pUxdL5\/b5xkdh2DJ4\/N5q0Kz\/CEbaoKwbexnQDlSr3ldlIhs7UmBjC9gkpgG2l9fu6a0VZFBE8hvzYrw=\"}","answerType":"{\"iv\":\"z1V+GmSWJxSng0bXxnYNRA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ZDf5sBxR6rO+DdO\/yFmk\"}","creationDate":"{\"iv\":\"DBKid4Yiyr61GVLigJj20w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ORRPzySTa6vt7GQrJOGBvNZXXq4p\/ANinfanE\/51DbcDNw==\"}","description":"{\"iv\":\"aohDHKaO7c7Fl5vIueBkcA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"+ygmsnYAsEBLZRUV\"}","expirationDate":"{\"iv\":\"Y0O4n9+Tj+4LSmLoFTaNow==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"jCz8DFIS5eLI4tsjfpr+F4lG+F27BItHPdj85o5+gaDayA==\"}","forceAnswer":"{\"iv\":\"P5Dg5Y9fS7EFxvqzP8u20A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"90G4jQ1PbalZyyzz\"}","isDateTime":"{\"iv\":\"3y9OmTJDG0mLqU5zLoZwgQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"yyGaGitGrunDSpsRpw==\"}","options":"{\"iv\":\"79HYzanMnjtgvBMowUWHaA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"HuFz0AFCpupdmXYdCcAX4OiwpMs\/Jm5XK\/thQW0phxKd0OxKt9NZ3FE\/rMAiYVqRKBqFp+KLhBnbs9ewTFW0Xrvw6paTnvpY9Ftcz1MB\"}","pollType":"{\"iv\":\"suOomfYe6kKBxjln091tCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"7iDQ2y571OBiJNxdaUY0PjqlgQ==\"}","timezone":"{\"iv\":\"l0VeY3CPUvMtoDPrw7+iCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"qBlHlZ0nLd3mqA==\"}","title":"{\"iv\":\"szAOrvhM+bODnldJJP0pGw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"KwMkE7bneP0MX6hQEnM=\"}","version":"v0.3.0+0ae62f31","serverExpirationDate":"2015-01-01T00:00:00.000Z"}';
$pollJson = <<<EOD
{
"anonymousUser": "{\"iv\":\"SOqei2Y7QZt1PFR6IXR4qg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"WAg0oSjCiMAO+JqzIg==\"}",
"answerType": "{\"iv\":\"z1V+GmSWJxSng0bXxnYNRA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ZDf5sBxR6rO+DdO/yFmk\"}",
"creationDate": "{\"iv\":\"DBKid4Yiyr61GVLigJj20w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ORRPzySTa6vt7GQrJOGBvNZXXq4p/ANinfanE/51DbcDNw==\"}",
"description": "{\"iv\":\"aohDHKaO7c7Fl5vIueBkcA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"+ygmsnYAsEBLZRUV\"}",
"expirationDate": "{\"iv\":\"Y0O4n9+Tj+4LSmLoFTaNow==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"jCz8DFIS5eLI4tsjfpr+F4lG+F27BItHPdj85o5+gaDayA==\"}",
"forceAnswer": "{\"iv\":\"P5Dg5Y9fS7EFxvqzP8u20A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"90G4jQ1PbalZyyzz\"}",
"isDateTime": "{\"iv\":\"3y9OmTJDG0mLqU5zLoZwgQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"yyGaGitGrunDSpsRpw==\"}",
"options": "{\"iv\":\"79HYzanMnjtgvBMowUWHaA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"HuFz0AFCpupdmXYdCcAX4OiwpMs/Jm5XK/thQW0phxKd0OxKt9NZ3FE/rMAiYVqRKBqFp+KLhBnbs9ewTFW0Xrvw6paTnvpY9Ftcz1MB\"}",
"pollType": "{\"iv\":\"suOomfYe6kKBxjln091tCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"7iDQ2y571OBiJNxdaUY0PjqlgQ==\"}",
"timezone": "{\"iv\":\"l0VeY3CPUvMtoDPrw7+iCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"qBlHlZ0nLd3mqA==\"}",
"title": "{\"iv\":\"szAOrvhM+bODnldJJP0pGw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"KwMkE7bneP0MX6hQEnM=\"}",
"version": "v0.3.0+0ae62f31",
"serverExpirationDate": "2015-01-01T00:00:00.000Z"
}
EOD;
$pollDir = TEST_DATA_DIR . $pollId . '/';
$usersDir = $pollDir . 'users/';

View file

@ -1,88 +0,0 @@
<?php
$pollId = substr(md5(__FILE__), 0, 10);
$pollJson = '{"poll":{"encryptedTitle":"{\"iv\":\"G1QGS+OHz5Z6Y4Og/3UFRQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"rgMRyJep4e0+Jj+K0ZTqbJS1j/gaouoTCoSHgXFdccn5L9gHBo1JO7Sl\"}","encryptedDescription":"{\"iv\":\"StcBqdGghIip/N3gLFmTMQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"5fgh7XABR7OifXoqHxE+c89mnVwkKUAG+x7D+BOGzoZK8dGT\"}","encryptedPollType":"{\"iv\":\"JYFdfUTb6xLWja302/bAKQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"wW+J3cGGF1tQUNfxt4gENDcZXQ==\"}","encryptedAnswerType":"{\"iv\":\"i0lDlvIVg2Le8pBSb47CIA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"+LNTLmILvxsN1X6E+vWa\"}","encryptedAnswers":"{\"iv\":\"xQV29b/F+gvlLl0zDCB7yw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"n5CwLLhSE5d28NU2/rOB7o6tXXWdDE7/uPr951Rr2ZQsmhsadmVwYE0K3Cxt+Hif4Am1jliS+PFjgVralrsSB00vlIH53wvDqQmNdk1Q/2zIebsVhHueamL4REyXn+18uVrjRarioojwOPYJLxNJHh0kPHATd0TgJxTb87RXgqUvAr1xc6DL7hY+fIbGoa6Otzt+OqIPhRTpaL+My1TYFXWQSlJxpPVSOILe1G/y6wg3Cp1lx4aFdHmGOGmrW+EF5pW9XrIz4A+3kNapSyUsDyuMk8wejrJpRHNcFpIlyRkxgUU=\"}","encryptedOptions":"{\"iv\":\"ruAw1xvAVLh9D19ngrEDgw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"pl1JeMRamWBuScBb1QOT9eqheJG2KD3y8RjtoPhNVid90wmoOQDm6WGtwt+gz6QXQEWUmIIXt8lyAJTH7updSnceW1SihfDi7xMmPTOf/338uSt3RdA2q+F+skiT14gheXHMtSFQaeVGvS8QfDXQfJBY9zJYp+On\"}","encryptedCreationDate":"{\"iv\":\"H/eApLF+Ja7ebX/1tPg7+w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"8BhpHOrgM3L7XftckMdkXSFzXi2evkfheanKfcjMFxzYsg==\"}","encryptedForceAnswer":"{\"iv\":\"DapF8f4GhKPORoIrDPiRXg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"WfPqwu5yBkhrHJWR\"}","encryptedAnonymousUser":"{\"iv\":\"sIclbapCBCxkHi0QrlCqOA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"SXyhfk7DVgfVKfp7Kw==\"}","encryptedIsDateTime":"{\"iv\":\"CbB/QEzDlENL3qRK2ZjWxA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"TD0rnzzHaawAWyUP\"}","encryptedTimezone":"{\"iv\":\"wQQXNefWW5QC9VZ1KkQQmA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"5Zkx8f0WQAgBBG1s0DcxoHeA5Dc/fEI=\"}","version":"v0.3-0"}}';
$user1Json = '{"user":{"encryptedName":"{\"iv\":\"wibexCADUTTMP8vjmegTwA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"r6Vu5fzAgvXctXaCx70/Pr1ldaOE\"}","encryptedSelections":"{\"iv\":\"gCMgC5Rie++L3s42RGzQJg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"jTmkMEkFTUiy8vGJinXAUmi7u8RNQ/FnLgnd+CMKL2XtqjeZuXgfMMDAdTEZnzNQTK7p/IhHBZrcrksUTnWkv68+f76msMs3rOqnYi7jhVL7O7NZMVNXysAgzalrQ+78Zz8TqoJ1qIARksTTCOi7Md07XKkYptCr3QUu0r8kfgk3KbGDuIE3tS4gGuB5CLKuPfFcbE0DjWAcr9IIEXpSPgjzJyEAx3bDd89ZfbRE0RaMoAR7Vqx+L3Hs6pXoUSbtnBJOQypNQYqUYycWA/kxCcuQEBlHwIR5qq7c9VsXNBG9SfGSA62scmbg6pxVXd3jEyTaxw3+B5r705mpMgAEY6NiJykob34x8LThdJP7XZKfe/tyczcKAlcQtJ7ocQsac0l1gRLK6eKHcNs8I3Zzi5iBzyqZtg0OVHzI8NiYpjwvg7piTHawsujAZIYkw/S4Pt29wkbb/heWpUsdJOF2xlfYYkTHnrPbX5jwZbNIgA==\"}","encryptedCreationDate":"{\"iv\":\"NGuUKkuLbabetQG5co01ZA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"ratk7+pQ14nax+slSf7ttOk2IIbQx7W2iu3I5VuUIR8RGg==\"}","version":"v0.3-0","poll":"gpwW7uZhbP"}}';
$user2Json = '{"user":{"encryptedName":"{\"iv\":\"GsRvWloC3GYi+MoOCUQ1vg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"0upVYhihQhWZcWpXcx5xKGHOTKTcVptz\"}","encryptedSelections":"{\"iv\":\"1FQ0Bf91k3JQbr23AhTszg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"u6qAugU7o/gTUvjaBrSjlNsU1AJ5oIPiSqrXs7iC7117ss2iX0aENcwsGG09XUk+K1llyrGAI7Fp2uBqn6fyujpacJrJG5oO7SR7F8xwc5TpMlWp/CHN2C9VPdOnm8KhdDtt6IUbNV+McjBxa3FtNVttkF4FAtUGYSurrrEscRad7bvSVbYzYkMs+83xS/ui+pJ3NLuNPntfErRIJw3EKacaUfm2eHCftBVvPHTy3AQbJ9mSKy3tMch+qu1nLnyFSMKjRieCFOgkT3LkQcvfpSteV3V/UNfm82ERy7AYOB8KZ0hW1R/vDp2R+EjFS3/0cw+a8luW6HGcyY0fs18uIbsSUaLOiThKTjp9pYhupXEa9gz1DeZMC51M79Ha4YC9uy3AyG5hH29DYF5yhBPD1Z0iYcgosJ8TweiYN0AvlCYsy939VRSzFGeiI/ZFN76DF0YP1LAOK9bTXHN9n8oyDoQbBMKcY48/uWZZpCAvBw==\"}","encryptedCreationDate":"{\"iv\":\"gLvf5OObVV10vRrQbMcrDw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"sWHHRuInJDY=\",\"ct\":\"2S5gjZmZ8QQRPfovPpLTbvQLsurgNIHXhkG0Ze8OpTScCw==\"}","version":"v0.3-0","poll":"gpwW7uZhbP"}}';
$pollDir = 'tests/_tmp/data/' . $pollId . '/';
$userDir = $pollDir . 'user/';
mkdir($pollDir);
file_put_contents($pollDir . 'poll_data', $pollJson);
mkdir($userDir);
file_put_contents($userDir . '0', $user1Json);
file_put_contents($userDir . '1', $user2Json);
$I = new ApiTester($scenario);
$I->wantTo('get an existing legacy (v0.3.0) poll with users');
$I->sendGET('/polls/' . $pollId);
$I->seeResponseCodeIs(200);
$I->seeHttpHeader('Content-Type', 'application/json');
$I->seeHttpHeader('Expires', '-1');
$I->seeResponseIsJson();
$pollData = json_decode($pollJson, true)["poll"];
unset($pollData["serverExpirationDate"]);
unset($pollData["encryptedIsDateTime"]);
foreach($pollData as $key => $value) {
if (strpos($key, 'encrypted') === 0) {
$key = lcfirst(substr($key, 9));
}
else {
$key = $key;
}
$I->seeResponseContainsJson(
array(
'poll' => array(
$key => $value
)
)
);
}
$I->seeResponseContainsJson(["poll" => ["id" => $pollId]]);
$I->dontSeeResponseJsonMatchesJsonPath('poll.serverExpirationDate');
$I->seeResponseJsonMatchesJsonPath('poll.users');
$users = $I->grabDataFromResponseByJsonPath('poll.users')[0];
\PHPUnit_Framework_Assert::assertTrue(
is_array($users),
'user should be an array'
);
\PHPUnit_Framework_Assert::assertEquals(
count($users),
2,
'user array should contain 2 users'
);
function wellformUser($user) {
$return = $user["user"];
foreach ($return as $key => $value) {
if(strpos($key, 'encrypted') === 0) {
$return[lcfirst(substr($key, 9))] = $value;
unset($return[$key]);
}
}
return $return;
}
$I->seeResponseContainsJson([
"poll" => [
"users" => [
wellformUser(json_decode($user1Json, true)),
wellformUser(json_decode($user2Json, true))
]
]
]);
$I->seeResponseJsonMatchesJsonPath('poll.users.0.id');
$I->seeResponseJsonMatchesJsonPath('poll.users.1.id');
$user1Id = $I->grabDataFromResponseByJsonPath('poll.users.0.id')[0];
$user2Id = $I->grabDataFromResponseByJsonPath('poll.users.1.id')[0];
\PHPUnit_Framework_Assert::assertTrue(
$user1Id !== $user2Id,
'user ids are unique'
);
\PHPUnit_Framework_Assert::assertEquals(
explode('_', $user1Id)[0],
$pollId,
'user id starts by poll id'
);

View file

@ -1,7 +1,24 @@
<?php
$pollId = substr(md5(__FILE__), 0, 10);
$pollJson = '{"anonymousUser":"{\"iv\":\"SOqei2Y7QZt1PFR6IXR4qg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"WAg0oSjCiMAO+JqzIg==\"}","answers":"{\"iv\":\"WRdAwEa0DF+E83ginLYtPw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"Oaer31ct2PXkmXkzJ1EXRPM3LMf6vGfzMZqjODwey4f7EhqSCUhYov+N7AZKCAAXYVS4WR84kKizxXBK2PQBSFrlB3Bll74ED9ZzRJSJD00otMG9BbgUR90aFws+1jMBP5vpti9+POsii85zLbDPkNg\/Th\/C4Ufv5YWwg\/4ZV0bFMyOgfdjtOWaG5YAMTGUIkz9U9+VCesYJQaTb497qTD\/Wmtz8J\/2pUxdL5\/b5xkdh2DJ4\/N5q0Kz\/CEbaoKwbexnQDlSr3ldlIhs7UmBjC9gkpgG2l9fu6a0VZFBE8hvzYrw=\"}","answerType":"{\"iv\":\"z1V+GmSWJxSng0bXxnYNRA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ZDf5sBxR6rO+DdO\/yFmk\"}","creationDate":"{\"iv\":\"DBKid4Yiyr61GVLigJj20w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ORRPzySTa6vt7GQrJOGBvNZXXq4p\/ANinfanE\/51DbcDNw==\"}","description":"{\"iv\":\"aohDHKaO7c7Fl5vIueBkcA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"+ygmsnYAsEBLZRUV\"}","expirationDate":"{\"iv\":\"Y0O4n9+Tj+4LSmLoFTaNow==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"jCz8DFIS5eLI4tsjfpr+F4lG+F27BItHPdj85o5+gaDayA==\"}","forceAnswer":"{\"iv\":\"P5Dg5Y9fS7EFxvqzP8u20A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"90G4jQ1PbalZyyzz\"}","options":"{\"iv\":\"79HYzanMnjtgvBMowUWHaA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"HuFz0AFCpupdmXYdCcAX4OiwpMs\/Jm5XK\/thQW0phxKd0OxKt9NZ3FE\/rMAiYVqRKBqFp+KLhBnbs9ewTFW0Xrvw6paTnvpY9Ftcz1MB\"}","pollType":"{\"iv\":\"suOomfYe6kKBxjln091tCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"7iDQ2y571OBiJNxdaUY0PjqlgQ==\"}","timezone":"{\"iv\":\"l0VeY3CPUvMtoDPrw7+iCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"qBlHlZ0nLd3mqA==\"}","title":"{\"iv\":\"szAOrvhM+bODnldJJP0pGw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"KwMkE7bneP0MX6hQEnM=\"}","version":"v0.3.0+0ae62f31","serverExpirationDate":"' . date("Y-m-dTH:i:s.000Z", strtotime("+3 month")) . '"}';
$expirationDate = date("Y-m-dTH:i:s.000Z", strtotime("+3 month"));
$pollJson = <<<EOD
{
"anonymousUser": "{\"iv\":\"SOqei2Y7QZt1PFR6IXR4qg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"WAg0oSjCiMAO+JqzIg==\"}",
"answerType": "{\"iv\":\"z1V+GmSWJxSng0bXxnYNRA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ZDf5sBxR6rO+DdO/yFmk\"}",
"creationDate": "{\"iv\":\"DBKid4Yiyr61GVLigJj20w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ORRPzySTa6vt7GQrJOGBvNZXXq4p/ANinfanE/51DbcDNw==\"}",
"description": "{\"iv\":\"aohDHKaO7c7Fl5vIueBkcA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"+ygmsnYAsEBLZRUV\"}",
"expirationDate": "{\"iv\":\"Y0O4n9+Tj+4LSmLoFTaNow==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"jCz8DFIS5eLI4tsjfpr+F4lG+F27BItHPdj85o5+gaDayA==\"}",
"forceAnswer": "{\"iv\":\"P5Dg5Y9fS7EFxvqzP8u20A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"90G4jQ1PbalZyyzz\"}",
"options": "{\"iv\":\"79HYzanMnjtgvBMowUWHaA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"HuFz0AFCpupdmXYdCcAX4OiwpMs/Jm5XK/thQW0phxKd0OxKt9NZ3FE/rMAiYVqRKBqFp+KLhBnbs9ewTFW0Xrvw6paTnvpY9Ftcz1MB\"}",
"pollType": "{\"iv\":\"suOomfYe6kKBxjln091tCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"7iDQ2y571OBiJNxdaUY0PjqlgQ==\"}",
"timezone": "{\"iv\":\"l0VeY3CPUvMtoDPrw7+iCw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"qBlHlZ0nLd3mqA==\"}",
"title": "{\"iv\":\"szAOrvhM+bODnldJJP0pGw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"KwMkE7bneP0MX6hQEnM=\"}",
"version": "v0.3.0+0ae62f31",
"serverExpirationDate": "$expirationDate"
}
EOD;
mkdir('tests/_tmp/data/' . $pollId);
file_put_contents('tests/_tmp/data/' . $pollId . '/poll_data', $pollJson);

View file

@ -1,9 +1,41 @@
<?php
$pollId = substr(md5(__FILE__), 0, 10);
$pollJson = '{"anonymousUser":"{\"iv\":\"gVHZSXyMm10Fn+kDooa7uw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"GJsQQYA7TdAa+v3Rvg==\"}","answers":"{\"iv\":\"aK1JcI3viLPIlOO45K+ePA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"Bx4SRcww+hJ46NIiVcWBUZHADADX\/XPsxXMx4XzMQZWqu6M0690D4oTflSRJoqxe0egxdfMOUxuWhmACG\/UYXSYJQjcSg+QTq6KJbaXG+SvsCMZ7iz12a\/uf9lXyiag4IbLldgL4vE3LfZO6oih\/o\/yG4hechjNdSkqUa2IvsRbXWB2aHen6a5Ch5WjqWrr4xRRrukPvf7aumilT2Cf0LswHJ2fwYNilylV0h9oegKYp+qWphm4SL8x2ogRemSCt7u7ByEOwZV0w6D9bz9RvGLTRRLJaLIm\/VlE3k7R6Hz1vyps=\"}","answerType":"{\"iv\":\"ILkAzgUfAGNUtLr7CbEJEQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"nMOp+QApQGgP9dwefNpi\"}","creationDate":"{\"iv\":\"6tWbieK03uXUR+E0AMbs0A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"YkkLVBkFyx4xFldZ7qnDESG0teHJmXaPMUB05p9L0xUIMg==\"}","description":"{\"iv\":\"fWvHh47So4WBNfEHXrwLiA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"5W7nauOakSoFD52V\"}","expirationDate":"{\"iv\":\"HRsMvEQaoCp8QdqBGHevnA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"LXYamNRDyhIY5xY+CLqI4GHbocc9NoHQtePKU9fHpJn9zg==\"}","forceAnswer":"{\"iv\":\"bh4iZ4pKe0GnXcM764702g==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"q5VBynWGotXRrc2P\"}","options":"{\"iv\":\"ZneP\/x45NGh\/DC26GI4kvg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"4MvV9SNQq2dB6b\/MdX47R0KaRSfyZOZMEVUFDv7G3\/EcDBv7Z0pgSU9JXoF8BoSOz40rYrRtTw==\"}","pollType":"{\"iv\":\"j3P6eN0ZmNMMxLTAVD6gjQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"opwiZHAQi+I8R5HDxLfLK59DcQ==\"}","timezone":"{\"iv\":\"HKkSqcJONggGT9QQ+jZdUg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"BANN8sJlk8JK9A==\"}","title":"{\"iv\":\"4DX7dAJt7JIBHaR1V0Ct8A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"f1VUZf69nB94TF3\/HA==\"}","version":"v0.3.0+0ae62f31","serverExpirationDate":"' . date("Y-m-dTH:i:s.000Z", strtotime("+3 month")) . '"}';
$user1Json = '{"name":"{\"iv\":\"GJXPSYYmTVfEsst31BD92w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"smbuxujRLF/xNS1syTWFguE=\"}","selections":"{\"iv\":\"WXlkCM3pGyD+SyIhccmHYg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"UHs2ArTVTHfS04J3HNvwrV68xFqra0Q0qtVpLZRLPUbqgdXmn960FjEFiLa9Cnk5OcKEQtbOgWJXehHlAJFAWFpdyDE/gcCOKG62c2/hVauroeycQE16wDCjrEwor/FV9HxNjTbYxoJASjCy9ROLdOUhSlFfQfHLcvVpsTgPpnKPr7aYBgODu5XIdRI8Pf5nYF0K96KE9xn+mkg3ZjyXWSk1LBaBDpIOCrcj+8zl7tLtkgPNfh8aNVgQHC5hRrIbL9kZwD4XXEUPImRFITEy2rUWKp8Q0/jAgHCnqSzOLOFS8KrJOktDX++DjK3cB4oT5ttLvcRxRQ==\"}","creationDate":"{\"iv\":\"3/KUsITWzJNWx5fDzYC9Xg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"llgQE2GpZDg1ZKRRlXliGlF09VsrVZ1R57EIQ21+dej5yg==\"}","version":"v0.3.0+0ae62f31","poll":"l3zyFJUWcQ"}';
$user2Json = '{"name":"{\"iv\":\"DVNTCOFfACEOrgtVNVMyww==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"eug7bstOm7T\/CCFs32o=\"}","selections":"{\"iv\":\"ubEuXoXzw4QFuzjAyvXC6w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"PfQ4v4hkBf+S0GX7JmnIp2LO5sh\/jg9nEIPn8NeU2Gn9Rb7cqsjCLgKOQ2xkiIzCyimVBOYg0fjGCyzM\/b6ZPQnY+86teNGogEteD4fjqGHhO832FNOy7Oci0YC8VAM1x9SlQNBI9V+vFc706JbZgwA8JY46UMiGK3HU49pgbYMpdnWEmt4dGzGrLMnNbh4J1Or5JydKmrp4dXaMiiggSXhmUTgBJSRhF7dxQm16oaA1lJpCWoQBvu+WTJv34LnBXHbgg6JcAEEONaQRw1jmMeqo36tQJxSdjiVfcDWzMifWiz\/nhQMqDHkc19iOAmDBo2Rf+yrGWA==\"}","creationDate":"{\"iv\":\"Sj4pVW\/maHa8DUNFHhyUrw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"HaY9MtEzVmEg3dxtI\/pfaIrsivBJSNeC5l5iJHQrvyYQGA==\"}","version":"v0.3.0+0ae62f31","poll":"l3zyFJUWcQ"}';
$expirationDate = date("Y-m-dTH:i:s.000Z", strtotime("+3 month"));
$pollJson = <<<EOD
{
"anonymousUser": "{\"iv\":\"gVHZSXyMm10Fn+kDooa7uw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"GJsQQYA7TdAa+v3Rvg==\"}",
"answerType": "{\"iv\":\"ILkAzgUfAGNUtLr7CbEJEQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"nMOp+QApQGgP9dwefNpi\"}",
"creationDate": "{\"iv\":\"6tWbieK03uXUR+E0AMbs0A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"YkkLVBkFyx4xFldZ7qnDESG0teHJmXaPMUB05p9L0xUIMg==\"}",
"description": "{\"iv\":\"fWvHh47So4WBNfEHXrwLiA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"5W7nauOakSoFD52V\"}",
"expirationDate": "{\"iv\":\"HRsMvEQaoCp8QdqBGHevnA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"LXYamNRDyhIY5xY+CLqI4GHbocc9NoHQtePKU9fHpJn9zg==\"}",
"forceAnswer": "{\"iv\":\"bh4iZ4pKe0GnXcM764702g==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"q5VBynWGotXRrc2P\"}",
"options": "{\"iv\":\"ZneP/x45NGh/DC26GI4kvg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"4MvV9SNQq2dB6b/MdX47R0KaRSfyZOZMEVUFDv7G3/EcDBv7Z0pgSU9JXoF8BoSOz40rYrRtTw==\"}",
"pollType": "{\"iv\":\"j3P6eN0ZmNMMxLTAVD6gjQ==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"opwiZHAQi+I8R5HDxLfLK59DcQ==\"}",
"timezone": "{\"iv\":\"HKkSqcJONggGT9QQ+jZdUg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"BANN8sJlk8JK9A==\"}",
"title": "{\"iv\":\"4DX7dAJt7JIBHaR1V0Ct8A==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"f1VUZf69nB94TF3/HA==\"}",
"version": "v0.3.0+0ae62f31",
"serverExpirationDate": "$expirationDate"
}
EOD;
$user1Json = <<<EOD
{
"name": "{\"iv\":\"GJXPSYYmTVfEsst31BD92w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"smbuxujRLF/xNS1syTWFguE=\"}",
"selections": "{\"iv\":\"WXlkCM3pGyD+SyIhccmHYg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"UHs2ArTVTHfS04J3HNvwrV68xFqra0Q0qtVpLZRLPUbqgdXmn960FjEFiLa9Cnk5OcKEQtbOgWJXehHlAJFAWFpdyDE/gcCOKG62c2/hVauroeycQE16wDCjrEwor/FV9HxNjTbYxoJASjCy9ROLdOUhSlFfQfHLcvVpsTgPpnKPr7aYBgODu5XIdRI8Pf5nYF0K96KE9xn+mkg3ZjyXWSk1LBaBDpIOCrcj+8zl7tLtkgPNfh8aNVgQHC5hRrIbL9kZwD4XXEUPImRFITEy2rUWKp8Q0/jAgHCnqSzOLOFS8KrJOktDX++DjK3cB4oT5ttLvcRxRQ==\"}",
"creationDate": "{\"iv\":\"3/KUsITWzJNWx5fDzYC9Xg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"llgQE2GpZDg1ZKRRlXliGlF09VsrVZ1R57EIQ21+dej5yg==\"}",
"version": "v0.3.0+0ae62f31",
"poll": "l3zyFJUWcQ"
}
EOD;
$user2Json = <<<EOD
{
"name": "{\"iv\":\"DVNTCOFfACEOrgtVNVMyww==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"eug7bstOm7T/CCFs32o=\"}",
"selections": "{\"iv\":\"ubEuXoXzw4QFuzjAyvXC6w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"PfQ4v4hkBf+S0GX7JmnIp2LO5sh/jg9nEIPn8NeU2Gn9Rb7cqsjCLgKOQ2xkiIzCyimVBOYg0fjGCyzM/b6ZPQnY+86teNGogEteD4fjqGHhO832FNOy7Oci0YC8VAM1x9SlQNBI9V+vFc706JbZgwA8JY46UMiGK3HU49pgbYMpdnWEmt4dGzGrLMnNbh4J1Or5JydKmrp4dXaMiiggSXhmUTgBJSRhF7dxQm16oaA1lJpCWoQBvu+WTJv34LnBXHbgg6JcAEEONaQRw1jmMeqo36tQJxSdjiVfcDWzMifWiz/nhQMqDHkc19iOAmDBo2Rf+yrGWA==\"}",
"creationDate": "{\"iv\":\"Sj4pVW/maHa8DUNFHhyUrw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"mhO9ROu+dr4=\",\"ct\":\"HaY9MtEzVmEg3dxtI/pfaIrsivBJSNeC5l5iJHQrvyYQGA==\"}",
"version": "v0.3.0+0ae62f31",
"poll": "l3zyFJUWcQ"
}
EOD;
$pollDir = 'tests/_tmp/data/' . $pollId . '/';
$userDir = $pollDir . 'user/';

View file

@ -1,7 +1,6 @@
<?php
$pollTemplate = array(
"anonymousUser" => "{\"iv\":\"SOqei2Y7QZt1PFR6IXR4qg==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"WAg0oSjCiMAO+JqzIg==\"}",
"answers" => "{\"iv\":\"WRdAwEa0DF+E83ginLYtPw==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"Oaer31ct2PXkmXkzJ1EXRPM3LMf6vGfzMZqjODwey4f7EhqSCUhYov+N7AZKCAAXYVS4WR84kKizxXBK2PQBSFrlB3Bll74ED9ZzRJSJD00otMG9BbgUR90aFws+1jMBP5vpti9+POsii85zLbDPkNg\/Th\/C4Ufv5YWwg\/4ZV0bFMyOgfdjtOWaG5YAMTGUIkz9U9+VCesYJQaTb497qTD\/Wmtz8J\/2pUxdL5\/b5xkdh2DJ4\/N5q0Kz\/CEbaoKwbexnQDlSr3ldlIhs7UmBjC9gkpgG2l9fu6a0VZFBE8hvzYrw=\"}",
"answerType" => "{\"iv\":\"z1V+GmSWJxSng0bXxnYNRA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ZDf5sBxR6rO+DdO\/yFmk\"}",
"creationDate" => "{\"iv\":\"DBKid4Yiyr61GVLigJj20w==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"ORRPzySTa6vt7GQrJOGBvNZXXq4p\/ANinfanE\/51DbcDNw==\"}",
"description" => "{\"iv\":\"aohDHKaO7c7Fl5vIueBkcA==\",\"v\":1,\"iter\":1000,\"ks\":128,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"3gtpUTAyVK4=\",\"ct\":\"+ygmsnYAsEBLZRUV\"}",

View file

@ -1,24 +0,0 @@
import RESTAdapter from '@ember-data/adapter/rest';
import { inject as service } from '@ember/service';
import AdapterFetch from 'ember-fetch/mixins/adapter-fetch';
export default class ApplicationAdapter extends RESTAdapter.extend(AdapterFetch) {
@service
encryption;
// set namespace to api.php in same subdirectory
namespace =
window.location.pathname
// remove index.html if it's there
.replace(/index.html$/, '')
// remove tests prefix which is added by testem (starting with a number)
.replace(/\/\d+\/tests/, '')
// remove tests prefix which is added by tests run in browser
.replace(/tests/, '')
// remove leading and trailing slash
.replace(/\/$/, '')
// add api.php
.concat('/api/index.php')
// remove leading slash
.replace(/^\//g, '')
}

View file

@ -1,7 +1,7 @@
import Application from '@ember/application';
import Resolver from 'ember-resolver';
import loadInitializers from 'ember-load-initializers';
import config from './config/environment';
import config from 'croodle/config/environment';
export default class App extends Application {
modulePrefix = config.modulePrefix;

View file

@ -1,15 +0,0 @@
import classic from 'ember-classic-decorator';
import Component from '@ember/component';
@classic
export default class AutofocusableElement extends Component {
autofocus = true;
didInsertElement() {
super.didInsertElement(...arguments);
if (this.autofocus) {
this.element.focus();
}
}
}

View file

@ -1,9 +1,14 @@
<BsButton
@disabled={{@disabled}}
@onClick={{@onClick}}
class="cr-steps-bottom-nav__button cr-steps-bottom-nav__prev-button prev"
data-test-action="back"
...attributes
>
<span class="cr-steps-bottom-nav__icon oi oi-caret-left" title={{t "action.back"}} aria-hidden="true"></span>
<span
class="cr-steps-bottom-nav__icon oi oi-caret-left"
title={{t "action.back"}}
aria-hidden="true"
></span>
<span class="cr-steps-bottom-nav__label">
{{t "action.back"}}
</span>

View file

@ -0,0 +1,16 @@
import templateOnlyComponent from '@ember/component/template-only';
interface BackButtonSignature {
Args: { onClick?: () => void };
Element: HTMLButtonElement;
}
const BackButton = templateOnlyComponent<BackButtonSignature>();
export default BackButton;
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
BackButton: typeof BackButton;
}
}

28
app/components/bs-form.js Normal file
View file

@ -0,0 +1,28 @@
import BaseBsForm from 'ember-bootstrap/components/bs-form';
import IntlMessage from '../utils/intl-message';
export default class BsForm extends BaseBsForm {
'__ember-bootstrap_subclass' = true;
get hasValidator() {
return true;
}
async validate(model) {
const isInvalid = Object.getOwnPropertyNames(
Object.getPrototypeOf(model),
).some((potentialValidationKey) => {
// Validation getters must be named `propertyValidation` by our convention
if (!potentialValidationKey.endsWith('Validation')) {
return false;
}
// Validation errors must be an instance of IntlMessage by convention
return model[potentialValidationKey] instanceof IntlMessage;
});
if (isInvalid) {
throw new Error();
}
}
}

View file

@ -0,0 +1,26 @@
import BaseBsFormElement from 'ember-bootstrap/components/bs-form/element';
import { inject as service } from '@ember/service';
export default class BsFormElement extends BaseBsFormElement {
'__ember-bootstrap_subclass' = true;
@service intl;
get errors() {
// native validation state doesn't integrate with Ember's autotracking, so we need to invalidate our `errors` getter explicitly when
// `this.value` changes by consuming it here.
// eslint-disable-next-line no-unused-vars
const { model, property } = this.args;
const validation = model[`${property}Validation`];
if (validation === undefined || validation === null) {
return [];
}
return [this.intl.t(validation.key, validation.options)];
}
get hasValidator() {
return true;
}
}

View file

@ -1,13 +0,0 @@
import classic from 'ember-classic-decorator';
import BaseBsInput from 'ember-bootstrap/components/bs-form/element/control/input';
@classic
export default class CustomizedBsInput extends BaseBsInput {
didInsertElement() {
super.didInsertElement(...arguments);
if (this.autofocus) {
this.element.focus();
}
}
}

View file

@ -0,0 +1,42 @@
<div class="cr-form-wrapper box">
<BsForm
@formLayout="horizontal"
@model={{@formData}}
@onSubmit={{this.submit}}
as |form|
>
<form.element
@label={{t "create.index.input.pollType.label"}}
@property="pollType"
@showValidationOn={{array "change" "focusOut"}}
@useIcons={{false}}
class="poll-type"
data-test-form-element="poll-type"
as |el|
>
<select
id={{el.id}}
class="form-control"
required
{{on "change" (pick "target.value" el.setValue)}}
{{autofocus}}
>
<option value="FindADate" selected={{eq el.value "FindADate"}}>
{{t "pollTypes.findADate.label"}}
</option>
<option value="MakeAPoll" selected={{eq el.value "MakeAPoll"}}>
{{t "pollTypes.makeAPoll.label"}}
</option>
</select>
</form.element>
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-8 order-12">
<NextButton />
</div>
<div class="col-6 col-md-4 order-1 text-right">
<BackButton disabled />
</div>
</div>
</BsForm>
</div>

View file

@ -0,0 +1,38 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { registerDestructor } from '@ember/destroyable';
import type RouterService from '@ember/routing/router-service';
import type { CreateIndexRouteModel } from '../routes/create/index';
export interface CreateIndexSignature {
Args: {
formData: CreateIndexRouteModel['formData'];
poll: CreateIndexRouteModel['poll'];
};
}
export default class CreateIndexComponent extends Component<CreateIndexSignature> {
@service declare router: RouterService;
@action
submit() {
this.router.transitionTo('create.meta');
}
constructor(owner: unknown, args: CreateIndexSignature['Args']) {
super(owner, args);
registerDestructor(this, () => {
const { poll, formData } = this.args;
poll.pollType = formData.pollType;
});
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
CreateIndex: typeof CreateIndexComponent;
}
}

View file

@ -0,0 +1,45 @@
<div class="cr-form-wrapper box">
<BsForm
@formLayout="horizontal"
@model={{@formData}}
@onInvalid={{(scroll-first-invalid-element-into-view-port)}}
@onSubmit={{this.submit}}
novalidate
as |form|
>
<form.element
@controlType="text"
@label={{t "create.meta.input.title.label"}}
@property="title"
class="title"
data-test-form-element="title"
as |el|
>
<el.control
placeholder={{t "create.meta.input.title.placeholder"}}
{{autofocus}}
/>
</form.element>
<form.element
@controlType="textarea"
@label={{t "create.meta.input.description.label"}}
@property="description"
class="description"
data-test-form-element="description"
as |el|
>
<el.control
placeholder={{t "create.meta.input.description.placeholder"}}
/>
</form.element>
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-8 order-12">
<NextButton />
</div>
<div class="col-6 col-md-4 order-1 text-right">
<BackButton @onClick={{this.previousPage}} />
</div>
</div>
</BsForm>
</div>

View file

@ -0,0 +1,44 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { registerDestructor } from '@ember/destroyable';
import type RouterService from '@ember/routing/router-service';
import type { CreateMetaRouteModel } from '../routes/create/meta';
export interface CreateMetaSignature {
Args: {
formData: CreateMetaRouteModel['formData'];
poll: CreateMetaRouteModel['poll'];
};
}
export default class CreateMetaComponent extends Component<CreateMetaSignature> {
@service declare router: RouterService;
@action
previousPage() {
this.router.transitionTo('create.index');
}
@action
submit() {
this.router.transitionTo('create.options');
}
constructor(owner: unknown, args: CreateMetaSignature['Args']) {
super(owner, args);
registerDestructor(this, () => {
const { poll, formData } = this.args;
poll.title = formData.title;
poll.description = formData.description;
});
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
CreateMeta: typeof CreateMetaComponent;
}
}

View file

@ -1,37 +1,35 @@
{{#let @form as |form|}}
<form.element
{{#let @formElement as |FormElement|}}
<FormElement
@label={{t "create.options.dates.label"}}
@property="options"
data-test-form-element-for="days"
as |el|
>
<div
class="
form-control
cr-h-auto
cr-pr-validation
{{if (eq el.validation "error") "is-invalid"}}
{{if (eq el.validation "success") "is-valid"}}
"
class="form-control cr-h-auto cr-pr-validation
{{if (eq el.validation 'error') 'is-invalid'}}
{{if (eq el.validation 'success') 'is-valid'}}
"
id={{el.id}}
>
<div class="row">
<div class="col-12 col-md-6">
<InlineDatepicker
@center={{this.calendarCenter}}
@selectedDays={{this.selectedDays}}
@onCenterChange={{action (mut this.calendarCenter) value="moment"}}
@onSelect={{action "daysSelected"}}
@onCenterChange={{fn this.handleCalenderCenterChange 0}}
@onSelect={{this.handleSelectedDaysChange}}
/>
</div>
<div class="col-md-6 cr-hide-on-mobile">
<InlineDatepicker
@center={{this.calendarCenterNext}}
@selectedDays={{this.selectedDays}}
@onCenterChange={{action (mut this.calendarCenter) value="moment"}}
@onSelect={{action "daysSelected"}}
@onCenterChange={{fn this.handleCalenderCenterChange -1}}
@onSelect={{this.handleSelectedDaysChange}}
/>
</div>
</div>
</div>
</form.element>
</FormElement>
{{/let}}

View file

@ -1,87 +0,0 @@
import classic from 'ember-classic-decorator';
import { action, computed } from '@ember/object';
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { isArray } from '@ember/array';
import { isPresent } from '@ember/utils';
import moment from 'moment';
@classic
export default class CreateOptionsDates extends Component {
@service('store')
store;
@computed('options.[]')
get selectedDays() {
return this.options
// should be unique
.uniqBy('day')
// raw dates
.map(({ date }) => date)
// filter out invalid
.filter(moment.isMoment)
.toArray();
}
@computed('calendarCenter')
get calendarCenterNext() {
return moment(this.calendarCenter).add(1, 'months');
}
@action
daysSelected({ moment: newMoments }) {
let { options } = this;
if (!isArray(newMoments)) {
// special case: all options are unselected
options.clear();
return;
}
// array of options that represent days missing in updated selection
let removedOptions = options.filter((option) => {
return !newMoments.find((newMoment) => newMoment.format('YYYY-MM-DD') === option.day);
});
// array of moments that aren't represented yet by an option
let addedMoments = newMoments.filter((moment) => {
return !options.find((option) => moment.format('YYYY-MM-DD') === option.day);
});
// remove options that represent deselected days
options.removeObjects(removedOptions);
// add options for newly selected days
let newOptions = addedMoments.map((moment) => {
return this.store.createFragment('option', {
title: moment.format('YYYY-MM-DD'),
})
});
newOptions.forEach((newOption) => {
// options must be insert into options array at correct position
let insertBefore = options.find(({ date }) => {
if (!moment.isMoment(date)) {
// ignore options that do not represent a valid date
return false;
}
return date.isAfter(newOption.date);
});
let position = isPresent(insertBefore) ? options.indexOf(insertBefore) : options.length;
options.insertAt(position, newOption);
});
}
@action
updateCalenderCenter(diff) {
this.calendarCenter.add(diff, 'months');
this.notifyPropertyChange('calenderCenter');
}
init() {
super.init(arguments);
let { selectedDays } = this;
this.set('calendarCenter', selectedDays.length >= 1 ? selectedDays[0] : moment());
}
}

View file

@ -0,0 +1,63 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { DateTime } from 'luxon';
import { tracked } from '@glimmer/tracking';
import type { FormDataOption } from './create-options';
import type BsFormElementComponent from 'ember-bootstrap/components/bs-form/element';
export interface CreateOptionsDatesSignature {
Args: {
formElement: BsFormElementComponent;
options: Array<FormDataOption>;
updateOptions: (options: string[]) => void;
};
}
export default class CreateOptionsDates extends Component<CreateOptionsDatesSignature> {
@tracked calendarCenter =
this.selectedDays.length >= 1
? (this.selectedDays[0] as DateTime)
: DateTime.local();
get selectedDays(): DateTime[] {
return this.args.options.map(
({ value }) => DateTime.fromISO(value) as DateTime,
);
}
get calendarCenterNext() {
return this.calendarCenter.plus({ months: 1 });
}
@action
handleSelectedDaysChange({
datetime: newDatesAsLuxonDateTime,
}: {
datetime: DateTime[];
}) {
if (!isArray(newDatesAsLuxonDateTime)) {
// special case: all options are unselected
this.args.updateOptions([]);
return;
}
this.args.updateOptions(
newDatesAsLuxonDateTime.map((datetime) => datetime.toISODate() as string),
);
}
@action
handleCalenderCenterChange(
offset: number,
{ datetime }: { datetime: DateTime },
) {
this.calendarCenter = datetime.plus({ months: offset });
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
CreateOptionsDates: typeof CreateOptionsDates;
}
}

View file

@ -0,0 +1,127 @@
<div class="cr-form-wrapper box">
{{#if this.errorMessage}}
<BsAlert @type="warning">
{{t this.errorMessage}}
</BsAlert>
{{/if}}
<BsForm
@onInvalid={{(scroll-first-invalid-element-into-view-port)}}
@onSubmit={{this.submit}}
@formLayout="horizontal"
@model={{this.formData}}
novalidate
as |form|
>
<div class="days">
{{#each-in this.formData.datetimes as |date timeOptions|}}
{{!--
@glint-ignore
Types for value returned by `{{#each-in}}` are broken if used
with a `Map`. https://github.com/typed-ember/glint/issues/645
--}}
{{#each timeOptions as |timeOption indexInTimeOptions|}}
<div data-test-day={{date}}>
<form.element
@label={{format-date timeOption.jsDate dateStyle="full"}}
{{!
show label only for the first time of this date
}}
@invisibleLabel={{gt indexInTimeOptions 0}}
@model={{timeOption}}
@property="time"
class="option"
as |el|
>
<div class="input-group">
<el.control
@placeholder="00:00"
@type="time"
@value={{el.value}}
{{! focus input if it's the first one }}
{{autofocus enabled=timeOption.isFirstTimeOnFirstDate}}
{{! run validation for partially filled input on focusout event }}
{{on "focusout" (fn this.validateInput timeOption)}}
{{on "change" (fn this.validateInput timeOption)}}
{{!
Validation for partially input field must be reset if input is cleared.
But `@onChange` is not called and `focusout` event not triggered in that
scenario. Need to listen to additional events to ensure that partially
input validation is updated as soon as user fixed a partially input.
The `keyup` events captures all scenarios in which the input is cleared
using keyboard. `focusin` event is triggered if user clicks the clears
button provided by native input. As a fallback validation is rerun on
`focusout`.
As the time of implementation this was only affecting Chrome cause
Firefox does not consider partially time input as invalid, Edge prevents
partially filling in first place and Desktop Safari as well as IE 11
do not support `<input type="time">`.
}}
{{on "focusin" (fn this.updateInputValidation timeOption)}}
{{on "keyup" (fn this.updateInputValidation timeOption)}}
id={{el.id}}
/>
<div class="input-group-append">
<BsButton
@onClick={{fn this.formData.deleteOption timeOption}}
@type="link"
class="delete"
data-test-action="delete"
>
<span
class="oi oi-trash"
title={{t "create.options.button.delete.label"}}
aria-hidden="true"
></span>
<span class="sr-only">
{{t "create.options.button.delete.label"}}
</span>
</BsButton>
</div>
</div>
<BsButton
@onClick={{fn this.formData.addOption date}}
@type="link"
@size="sm"
class="add cr-option-menu__button cr-option-menu__add-button float-left"
data-test-action="add"
>
<span
class="oi oi-plus"
title={{t "create.options.button.add.label"}}
aria-hidden="true"
></span>
<span class="sr-only">{{t
"create.options.button.add.label"
}}</span>
</BsButton>
</form.element>
</div>
{{/each}}
{{/each-in}}
</div>
{{#if this.formData.hasMultipleDays}}
<form.element>
<BsButton
@onClick={{this.adoptTimesOfFirstDay}}
@size="sm"
class="adopt-times-of-first-day"
data-test-action="adopt-times-of-first-day"
>
{{t "create.options-datetime.copy-first-line"}}
</BsButton>
</form.element>
{{/if}}
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-8 order-12">
<NextButton />
</div>
<div class="col-6 col-md-4 order-1 text-right">
<BackButton @onClick={{this.previousPage}} />
</div>
</div>
</BsForm>
</div>

View file

@ -1,190 +0,0 @@
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { isPresent, isEmpty } from '@ember/utils';
import { action, get } from '@ember/object';
import {
validator, buildValidations
}
from 'ember-cp-validations';
import { raw } from 'ember-awesome-macros';
import { groupBy } from 'ember-awesome-macros/array';
import { next } from '@ember/runloop';
let modelValidations = buildValidations({
dates: [
validator('collection', true),
validator('length', {
dependentKeys: ['model.datetimes.[]'],
min: 1
}),
validator('valid-collection', {
dependentKeys: ['model.datetimes.[]', 'model.datetimes.@each.time']
})
]
});
export default class CreateOptionsDatetime extends Component.extend(modelValidations) {
@service
store;
errorMesage = null;
// group dates by day
@groupBy('dates', raw('day'))
groupedDates;
get datesForFirstDay() {
// dates are sorted
let firstDay = this.groupedDates[0];
return firstDay.items;
}
get timesForFirstDay() {
return this.datesForFirstDay.map((date) => date.time).filter((time) => isPresent(time));
}
@action
addOption(afterOption) {
let options = this.dates;
let dayString = afterOption.get('day');
let fragment = this.store.createFragment('option', {
title: dayString
});
let position = options.indexOf(afterOption) + 1;
options.insertAt(
position,
fragment
);
next(() => {
this.notifyPropertyChange('_nestedChildViews');
});
}
@action
adoptTimesOfFirstDay() {
const dates = this.dates;
const datesForFirstDay = this.datesForFirstDay;
const timesForFirstDay = this.timesForFirstDay;
const datesWithoutFirstDay = this.groupedDates.slice(1);
/* validate if times on firstDay are valid */
const datesForFirstDayAreValid = datesForFirstDay.every((date) => {
// ignore dates where time is null
return isEmpty(date.get('time')) || date.get('validations.isValid');
});
if (!datesForFirstDayAreValid) {
this.set('errorMessage', 'create.options-datetime.fix-validation-errors-first-day');
return;
}
datesWithoutFirstDay.forEach(({ items }) => {
if (isEmpty(timesForFirstDay)) {
// there aren't any times on first day
const remainingOption = items[0];
// remove all times but the first one
dates.removeObjects(
items.slice(1)
);
// set title as date without time
remainingOption.set('title', remainingOption.get('date').format('YYYY-MM-DD'));
} else {
// adopt times of first day
if (timesForFirstDay.get('length') < items.length) {
// remove excess options
dates.removeObjects(
items.slice(timesForFirstDay.get('length'))
);
}
// set times according to first day
let targetPosition;
timesForFirstDay.forEach((timeOfFirstDate, index) => {
const target = items[index];
if (target === undefined) {
const basisDate = get(items[0], 'date').clone();
let [hour, minute] = timeOfFirstDate.split(':');
let dateString = basisDate.hour(hour).minute(minute).toISOString();
let fragment = this.store.createFragment('option', {
title: dateString
});
dates.insertAt(
targetPosition,
fragment
);
targetPosition++;
} else {
target.set('time', timeOfFirstDate);
targetPosition = dates.indexOf(target) + 1;
}
});
}
});
}
/*
* removes target option if it's not the only date for this day
* otherwise it deletes time for this date
*/
@action
deleteOption(target) {
let position = this.dates.indexOf(target);
let datesForThisDay = this.groupedDates.find((group) => {
return group.value === target.get('day');
}).items;
if (datesForThisDay.length > 1) {
this.dates.removeAt(position);
} else {
target.set('time', null);
}
}
@action
previousPage() {
this.onPrevPage();
}
@action
submit() {
if (this.get('validations.isValid')) {
this.onNextPage();
} else {
this.set('shouldShowErrors', true);
}
}
@action
inputChanged(date, value) {
// update property, which is normally done by default
date.set('time', value);
// reset partially filled state
date.set('isPartiallyFilled', false);
// reset error message
this.set('errorMessage', null);
}
// validate input field for being partially filled
@action
validateInput(date, event) {
let element = event.target;
// update partially filled time validation error
if (!element.checkValidity()) {
date.set('isPartiallyFilled', true);
} else {
date.set('isPartiallyFilled', false);
}
}
// remove partially filled validation error if user fixed it
@action
updateInputValidation(date, event) {
let element = event.target;
if (element.checkValidity() && date.isPartiallyFilled) {
date.set('isPartiallyFilled', false);
}
}
}

View file

@ -0,0 +1,285 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { TrackedMap, TrackedSet } from 'tracked-built-ins';
import { DateTime } from 'luxon';
import IntlMessage from '../utils/intl-message';
import type RouterService from '@ember/routing/router-service';
import type Transition from '@ember/routing/transition';
import type { CreateOptionsDatetimeRouteModel } from 'croodle/routes/create/options-datetime';
class FormDataTimeOption {
formData;
// ISO 8601 date string: YYYY-MM-DD
date: string;
// ISO 8601 time string without seconds: HH:mm
@tracked time: string | null;
// helper property set by modifiers to track if input element is invalid
// because user only entered the time partly (e.g. "10:--").
@tracked isPartiallyFilled = false;
get timeValidation() {
const { isPartiallyFilled } = this;
if (isPartiallyFilled) {
return new IntlMessage(
'create.options-datetime.error.partiallyFilledTime',
);
}
// The same time must not be entered twice for a day.
// It should show a validation error if the same time has been entered for
// the same day already before. Only the second input field containing the
// duplicated time should show the validation error.
const { formData, date } = this;
const timesForThisDate = Array.from(formData.datetimes.get(date)!);
const isDuplicate = timesForThisDate
.slice(0, timesForThisDate.indexOf(this))
.some((timeOption) => timeOption.time == this.time);
if (isDuplicate) {
return new IntlMessage('create.options-datetime.error.duplicatedDate');
}
return null;
}
get datetime() {
const { date, time } = this;
const isoString = time === null ? date : `${date}T${time}`;
return DateTime.fromISO(isoString);
}
get jsDate() {
const { datetime } = this;
return datetime.toJSDate();
}
get isValid() {
const { timeValidation } = this;
return timeValidation === null;
}
get isFirstTimeOnFirstDate() {
const { formData, date } = this;
const { datetimes } = formData;
return (
Array.from(datetimes.keys())[0] === date &&
Array.from(datetimes.get(date)!)[0] === this
);
}
constructor(
formData: FormData,
{ date, time }: { date: string; time: string | null },
) {
this.formData = formData;
this.date = date;
this.time = time;
}
}
class FormData {
@tracked datetimes: Map<string, Set<FormDataTimeOption>>;
get optionsValidation() {
const { datetimes } = this;
const allTimeOptionsAreValid = Array.from(datetimes.values()).every(
(timeOptionsForDate) =>
Array.from(timeOptionsForDate).every(
(timeOption) => timeOption.isValid,
),
);
if (!allTimeOptionsAreValid) {
return new IntlMessage('create.options-datetime.error.invalidTime');
}
return null;
}
get hasMultipleDays() {
return this.datetimes.size > 1;
}
get validationStatePerDate() {
const validationState: Map<string, boolean> = new Map();
for (const [date, timeOptions] of this.datetimes.entries()) {
validationState.set(
date,
Array.from(timeOptions).every((time) => time.isValid),
);
}
return validationState;
}
@action
addOption(date: string) {
this.datetimes
.get(date)!
.add(new FormDataTimeOption(this, { date, time: null }));
}
/*
* removes target option if it's not the only time for this date
* otherwise it deletes time for this date
*/
@action
deleteOption(option: FormDataTimeOption) {
const timeOptionsForDate = this.datetimes.get(option.date)!;
if (timeOptionsForDate.size > 1) {
timeOptionsForDate.delete(option);
} else {
option.time = null;
}
}
@action
adoptTimesOfFirstDay() {
const timeOptionsForFirstDay = Array.from(
Array.from(this.datetimes.values())[0]!,
) as FormDataTimeOption[];
const timesForFirstDayAreValid = timeOptionsForFirstDay.every(
(timeOption) => timeOption.isValid,
);
if (!timesForFirstDayAreValid) {
return false;
}
for (const date of Array.from(this.datetimes.keys()).slice(1)) {
this.datetimes.set(
date,
new TrackedSet(
timeOptionsForFirstDay.map(
({ time }) => new FormDataTimeOption(this, { date, time }),
),
),
);
}
}
constructor({
dates,
times,
}: {
dates: Set<string>;
times: Map<string, Set<string>>;
}) {
const datetimes = new Map();
for (const date of dates) {
const timesForDate = times.has(date)
? Array.from(times.get(date) as Set<string>)
: [null];
datetimes.set(
date,
new TrackedSet(
timesForDate.map(
(time) => new FormDataTimeOption(this, { date, time }),
),
),
);
}
this.datetimes = new TrackedMap(datetimes);
}
}
export interface CreateOptoinsDatetimeSignature {
Args: {
poll: CreateOptionsDatetimeRouteModel;
};
}
export default class CreateOptionsDatetime extends Component<CreateOptoinsDatetimeSignature> {
@service declare router: RouterService;
formData = new FormData({
dates: this.args.poll.dateOptions,
times: this.args.poll.timesForDateOptions,
});
@tracked errorMessage: string | null = null;
@action
adoptTimesOfFirstDay() {
const { formData } = this;
const successful = formData.adoptTimesOfFirstDay();
if (!successful) {
this.errorMessage =
'create.options-datetime.fix-validation-errors-first-day';
}
}
@action
previousPage() {
this.router.transitionTo('create.options');
}
@action
submit() {
this.router.transitionTo('create.settings');
}
// validate input field for being partially filled
@action
validateInput(option: FormDataTimeOption, event: Event) {
const element = event.target as HTMLInputElement;
// update partially filled time validation error
option.isPartiallyFilled = !element.checkValidity();
}
// remove partially filled validation error if user fixed it
@action
updateInputValidation(option: FormDataTimeOption, event: Event) {
const element = event.target as HTMLInputElement;
if (element.checkValidity() && option.isPartiallyFilled) {
option.isPartiallyFilled = false;
}
}
@action
handleTransition(transition: Transition) {
if (transition.from?.name === 'create.options-datetime') {
this.args.poll.timesForDateOptions = new Map(
// FormData.datetimes Map has a Set of FormDataTime object as values
// We need to transform it to a Set of plain time strings
Array.from(this.formData.datetimes.entries())
.map(([key, timeOptions]): [string, Set<string>] => {
return [
key,
new Set(
Array.from(timeOptions)
.map(({ time }: FormDataTimeOption) => time)
// There might be FormDataTime objects without a time, which
// we need to filter out
.filter((time) => time !== null),
) as Set<string>,
];
})
// There might be dates without any time, which we need to filter out
.filter(([, times]) => times.size > 0),
);
this.router.off('routeWillChange', this.handleTransition);
}
}
constructor(owner: unknown, args: CreateOptoinsDatetimeSignature['Args']) {
super(owner, args);
this.router.on('routeWillChange', this.handleTransition);
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
CreateOptionsDatetime: typeof CreateOptionsDatetime;
}
}

View file

@ -0,0 +1,53 @@
{{#let @formElement as |FormElement|}}
{{#each @options as |option index|}}
<FormElement
{{! show label only on first item }}
@label={{unless index (t "create.options.options.label")}}
@model={{option}}
@property="value"
class="option"
data-test-form-element="option"
data-test-option={{index}}
as |el|
>
<div class="input-group">
<el.control
{{! first control should be focused automatically }}
{{autofocus enabled=(eq index 0)}}
/>
<div class="input-group-append">
<BsButton
@onClick={{fn @deleteOption option}}
@type="link"
class="delete"
{{! disable delete button if there is only one option }}
disabled={{lte @options.length 1}}
>
<span
class="oi oi-trash"
title={{t "create.options.button.delete.label"}}
aria-hidden="true"
></span>
<span class="sr-only">{{t
"create.options.button.delete.label"
}}</span>
</BsButton>
</div>
</div>
<BsButton
@onClick={{fn @addOption "" index}}
@type="link"
@size="sm"
class="add float-left"
>
<span
class="oi oi-plus"
title={{t "create.options.button.add.label"}}
aria-hidden="true"
></span>
<span class="sr-only">{{t "create.options.button.add.label"}}</span>
</BsButton>
</FormElement>
{{/each}}
{{/let}}

View file

@ -1,49 +0,0 @@
import classic from 'ember-classic-decorator';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { next } from '@ember/runloop';
@classic
export default class CreateOptionsText extends Component {
@action
addOption(element) {
let fragment = this.store.createFragment('option');
let options = this.options;
let position = this.options.indexOf(element) + 1;
options.insertAt(
position,
fragment
);
}
@action
deleteOption(element) {
let position = this.options.indexOf(element);
this.options.removeAt(position);
}
enforceMinimalOptionsAmount() {
let options = this.options;
while (options.length < 2) {
options.pushObject(
this.store.createFragment('option')
);
}
}
@service('store')
store;
init() {
super.init(...arguments);
// need to delay pushing fragments into options array to prevent
// > You modified "disabled" twice on <(unknown):ember330> in a single render.
// error.
next(() => {
this.enforceMinimalOptionsAmount();
});
}
}

View file

@ -0,0 +1,24 @@
import templateOnlyComponent from '@ember/component/template-only';
import type { FormDataOption } from './create-options';
import type BsFormElementComponent from 'ember-bootstrap/components/bs-form/element';
interface CreateOptionsTextSignature {
Args: {
Named: {
addOption: (value: string, afterPosition: number) => void;
deleteOption: (option: FormDataOption) => void;
formElement: BsFormElementComponent;
options: FormDataOption[];
};
};
}
const CreateOptionsText = templateOnlyComponent<CreateOptionsTextSignature>();
export default CreateOptionsText;
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
CreateOptionsText: typeof CreateOptionsText;
}
}

View file

@ -0,0 +1,34 @@
<div class="cr-form-wrapper box">
<BsForm
@formLayout="horizontal"
@model={{this.formData}}
@onInvalid={{(scroll-first-invalid-element-into-view-port)}}
@onSubmit={{this.submit}}
novalidate
as |form|
>
{{#if (eq @poll.pollType "MakeAPoll")}}
<CreateOptionsText
@options={{this.formData.options}}
@addOption={{this.formData.addOption}}
@deleteOption={{this.formData.deleteOption}}
@formElement={{form.element}}
/>
{{else}}
<CreateOptionsDates
@options={{this.formData.options}}
@updateOptions={{this.formData.updateOptions}}
@formElement={{form.element}}
/>
{{/if}}
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-8 order-12">
<NextButton />
</div>
<div class="col-6 col-md-4 order-1 text-right">
<BackButton @onClick={{this.previousPage}} />
</div>
</div>
</BsForm>
</div>

View file

@ -1,50 +0,0 @@
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import Component from '@ember/component';
import {
validator, buildValidations
}
from 'ember-cp-validations';
let Validations = buildValidations({
options: [
validator('collection', true),
validator('length', {
dependentKeys: ['model.options.[]', 'model.intl.locale'],
min: 1,
// it's impossible to delete all text options so this case could be ignored
// for validation error message
descriptionKey: 'create.options.error.notEnoughDates'
}),
validator('valid-collection', {
dependentKeys: ['model.options.[]', 'model.options.@each.title']
})
]
});
export default class CreateOptionsComponent extends Component.extend(Validations) {
shouldShowErrors = false;
// consumed by validator
@service intl;
@action
previousPage() {
this.onPrevPage();
}
@action
submit() {
if (this.get('validations.isValid')) {
this.onNextPage();
} else {
this.set('shouldShowErrors', true);
}
}
init() {
super.init(...arguments);
this.intl.locale;
}
}

View file

@ -0,0 +1,168 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { TrackedArray, TrackedSet } from 'tracked-built-ins';
import IntlMessage from '../utils/intl-message';
import { tracked } from '@glimmer/tracking';
import type RouterService from '@ember/routing/router-service';
import type { CreateOptionsRouteModel } from '../routes/create/options';
import type Transition from '@ember/routing/transition';
export class FormDataOption {
@tracked value;
formData;
get valueValidation() {
const { formData, value } = this;
// Every option must have a label
if (!value) {
return new IntlMessage('create.options.error.valueMissing');
}
// Options must be unique. There must not be another option having the
// same value before
const isUnique = !formData.options
.slice(0, this.formData.options.indexOf(this))
.some((option) => option.value === this.value);
if (!isUnique) {
return new IntlMessage('create.options.error.duplicatedOption');
}
return null;
}
get isValid() {
return this.valueValidation === null;
}
constructor(formData: FormData, value: string) {
this.formData = formData;
this.value = value;
}
}
class FormData {
@tracked options;
get optionsValidation() {
const { options } = this;
if (options.length < 1) {
// UI enforces that there is at least one option if poll type is `MakeAPoll`.
// This validation error can only happen if poll type is `FindADate`.
return new IntlMessage('create.options.error.notEnoughDates');
}
if (options.some((option) => !option.isValid)) {
return new IntlMessage('create.options.error.invalidOption');
}
return null;
}
@action
updateOptions(values: string[]) {
this.options = new TrackedArray(
values.map((value) => new FormDataOption(this, value)),
);
}
@action
addOption(value: string, afterPosition = this.options.length - 1) {
const option = new FormDataOption(this, value);
this.options.splice(afterPosition + 1, 0, option);
}
@action
deleteOption(option: FormDataOption) {
this.options.splice(this.options.indexOf(option), 1);
}
constructor(
{ options }: { options: Set<string> },
{ defaultOptionCount }: { defaultOptionCount: number },
) {
const normalizedOptions =
options.size === 0 && defaultOptionCount > 0
? ['', '']
: Array.from(options);
this.options = new TrackedArray(
normalizedOptions.map((value) => new FormDataOption(this, value)),
);
}
}
export interface CreateOptionsSignature {
Args: {
poll: CreateOptionsRouteModel;
};
}
export default class CreateOptions extends Component<CreateOptionsSignature> {
@service declare router: RouterService;
formData = new FormData(
{ options: this.options },
{ defaultOptionCount: this.args.poll.pollType === 'MakeAPoll' ? 2 : 0 },
);
get options() {
const { poll } = this.args;
const { dateOptions, freetextOptions, pollType } = poll;
return pollType === 'FindADate' ? dateOptions : freetextOptions;
}
@action
previousPage() {
this.router.transitionTo('create.meta');
}
@action
submit() {
const { pollType } = this.args.poll;
if (pollType === 'FindADate') {
this.router.transitionTo('create.options-datetime');
} else {
this.router.transitionTo('create.settings');
}
}
@action handleTransition(transition: Transition) {
if (transition.from?.name === 'create.options') {
this.updatePoll();
this.router.off('routeWillChange', this.handleTransition);
}
}
updatePoll() {
const { poll } = this.args;
const { pollType } = poll;
const { options } = this.formData;
const pollOptions = options.map(({ value }) => value);
if (pollType === 'FindADate') {
poll.dateOptions = new TrackedSet(pollOptions.sort());
} else {
poll.freetextOptions = new TrackedSet(pollOptions);
}
}
constructor(owner: unknown, args: CreateOptionsSignature['Args']) {
super(owner, args);
// Cannot use a destructor because that one runs _after_ the other component
// rendered by the next route is initialized.
this.router.on('routeWillChange', this.handleTransition);
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
CreateOptions: typeof CreateOptions;
}
}

View file

@ -0,0 +1,113 @@
<div class="cr-form-wrapper box">
<BsForm
@formLayout="horizontal"
@model={{this}}
@onInvalid={{(scroll-first-invalid-element-into-view-port)}}
@onSubmit={{this.createPoll}}
novalidate
as |form|
>
<form.element
@label={{t "create.settings.answerType.label"}}
@property="answerType"
@showValidationOn={{array "change" "focusOut"}}
@useIcons={{false}}
class="answer-type"
as |el|
>
<select
id={{el.id}}
class="custom-select"
{{on "change" (pick "target.value" el.setValue)}}
{{autofocus}}
>
{{#each this.answerTypes as |answerType|}}
<option
value={{answerType.id}}
selected={{eq el.value answerType.id}}
>
{{t answerType.labelTranslation}}
</option>
{{/each}}
</select>
</form.element>
<form.element
@controlType="select"
@label={{t "create.settings.expirationDate.label"}}
@property="expirationDuration"
@showValidationOn={{array "change" "focusOut"}}
@useIcons={{false}}
class="expiration-duration"
as |el|
>
<select
id={{el.id}}
{{on "change" (pick "target.value" el.setValue)}}
class="custom-select"
>
{{#each this.expirationDurations as |duration|}}
<option value={{duration.id}} selected={{eq el.value duration.id}}>
{{t duration.labelTranslation}}
</option>
{{/each}}
</select>
</form.element>
<form.element
@controlType="checkbox"
@label={{t "create.settings.anonymousUser.label"}}
@showValidationOn="change"
@property="anonymousUser"
class="anonymous-user"
/>
<form.element
@controlType="checkbox"
@label={{t "create.settings.forceAnswer.label"}}
@showValidationOn="change"
@property="forceAnswer"
class="force-answer"
/>
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-8 order-12">
<SaveButton
@isPending={{form.isSubmitting}}
data-test-button="submit"
/>
</div>
<div class="col-6 col-md-4 order-1 text-right">
<BackButton @onClick={{this.previousPage}} />
</div>
</div>
<BsModal
@onHidden={{this.resetSavingPollFailedState}}
@onSubmit={{form.submit}}
@open={{this.savingPollFailed}}
data-test-modal="saving-failed"
as |modal|
>
<modal.header
@closeButton={{false}}
@title={{t "error.poll.savingFailed.title"}}
/>
<modal.body>
<p>
{{t "error.poll.savingFailed.description"}}
</p>
</modal.body>
<modal.footer>
<BsButton @onClick={{modal.close}} data-test-button="abort">
{{t "action.abort"}}
</BsButton>
<SaveButton
@isPending={{form.isSubmitting}}
@onClick={{modal.submit}}
data-test-button="retry"
type="button"
>
{{t "modal.save-retry.button-retry"}}
</SaveButton>
</modal.footer>
</BsModal>
</BsForm>
</div>

View file

@ -0,0 +1,188 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { isPresent } from '@ember/utils';
import { DateTime, Duration } from 'luxon';
import { generatePassphrase } from '../utils/encryption';
import Poll from '../models/poll';
import type IntlService from 'ember-intl/services/intl';
import type RouterService from '@ember/routing/router-service';
import type { CreateSettingsRouteModel } from 'croodle/routes/create/settings';
export interface CreateSettingsSignature {
Args: {
poll: CreateSettingsRouteModel;
};
}
export default class CreateSettingsComponent extends Component<CreateSettingsSignature> {
@service declare intl: IntlService;
@service declare router: RouterService;
@tracked savingPollFailed = false;
get anonymousUser() {
return this.args.poll.anonymousUser;
}
set anonymousUser(value) {
this.args.poll.anonymousUser = value;
}
get answerType() {
return this.args.poll.answerType;
}
set answerType(value) {
this.args.poll.answerType = value;
}
get answerTypes() {
return [
{ id: 'YesNo', labelTranslation: 'answerTypes.yesNo.label' },
{ id: 'YesNoMaybe', labelTranslation: 'answerTypes.yesNoMaybe.label' },
{ id: 'FreeText', labelTranslation: 'answerTypes.freeText.label' },
];
}
get expirationDuration() {
// TODO: must be calculated based on model.expirationDate
return 'P3M';
}
set expirationDuration(value) {
this.args.poll.expirationDate = isPresent(value)
? (DateTime.local().plus(Duration.fromISO(value)).toISO() as string)
: '';
}
get expirationDurations() {
return [
{
id: 'P7D',
labelTranslation: 'create.settings.expirationDurations.P7D',
},
{
id: 'P1M',
labelTranslation: 'create.settings.expirationDurations.P1M',
},
{
id: 'P3M',
labelTranslation: 'create.settings.expirationDurations.P3M',
},
{
id: 'P6M',
labelTranslation: 'create.settings.expirationDurations.P6M',
},
{
id: 'P1Y',
labelTranslation: 'create.settings.expirationDurations.P1Y',
},
{ id: '', labelTranslation: 'create.settings.expirationDurations.never' },
];
}
get forceAnswer() {
return this.args.poll.forceAnswer;
}
set forceAnswer(value) {
this.args.poll.forceAnswer = value;
}
@action
previousPage() {
const { pollType } = this.args.poll;
if (pollType === 'FindADate') {
this.router.transitionTo('create.options-datetime');
} else {
this.router.transitionTo('create.options');
}
}
@action
async createPoll() {
const { poll } = this.args;
const {
anonymousUser,
answerType,
description,
expirationDate,
forceAnswer,
freetextOptions,
dateOptions,
timesForDateOptions,
pollType,
title,
} = poll;
// calculate options
const options: string[] = [];
if (pollType === 'FindADate') {
// merge date with times
for (const date of dateOptions) {
if (timesForDateOptions.has(date)) {
for (const time of timesForDateOptions.get(date)!) {
const [hour, minute] = time.split(':') as [string, string];
options.push(
DateTime.fromISO(date)
.set({
hour: parseInt(hour),
minute: parseInt(minute),
second: 0,
millisecond: 0,
})
.toISO() as string,
);
}
} else {
options.push(date);
}
}
} else {
options.push(...freetextOptions);
}
// save poll
try {
const encryptionKey = generatePassphrase();
// save poll
const poll = await Poll.create(
{
anonymousUser,
answerType,
description,
expirationDate,
forceAnswer,
options: options.map((option) => {
return { title: option };
}),
pollType,
title,
},
encryptionKey,
);
// redirect to new poll
await this.router.transitionTo('poll.participation', poll.id, {
queryParams: {
encryptionKey,
},
});
} catch (err) {
this.savingPollFailed = true;
reportError(err);
}
}
@action
resetSavingPollFailedState() {
this.savingPollFailed = false;
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
CreateSettings: typeof CreateSettingsComponent;
}
}

View file

@ -2,23 +2,24 @@
@center={{@center}}
@selected={{@selectedDays}}
@onCenterChange={{@onCenterChange}}
@onSelect={{@onSelect}} as |calendar|
@onSelect={{@onSelect}}
as |calendar|
>
<nav class="ember-power-calendar-nav">
<button
type="button"
class="ember-power-calendar-nav-control"
onclick={{action calendar.actions.moveCenter -1 "month"}}
{{on "click" (fn calendar.actions.moveCenter -1 "month")}}
>
«
</button>
<div class="ember-power-calendar-nav-title">
{{moment-format calendar.center "MMMM YYYY"}}
{{format-date calendar.center month="long" year="numeric"}}
</div>
<button
type="button"
class="ember-power-calendar-nav-control"
onclick={{action calendar.actions.moveCenter 1 "month"}}
{{on "click" (fn calendar.actions.moveCenter 1 "month")}}
>
»
</button>

View file

@ -1,7 +0,0 @@
import classic from 'ember-classic-decorator';
import { tagName } from '@ember-decorators/component';
import Component from '@ember/component';
@classic
@tagName('')
export default class InlineDatepicker extends Component {}

View file

@ -0,0 +1,23 @@
import templateOnlyComponent from '@ember/component/template-only';
import type { DateTime } from 'luxon';
interface InlineDatepickerSignature {
Args: {
Named: {
center: DateTime;
onCenterChange: (day: { datetime: DateTime }) => void;
onSelect: (days: { datetime: DateTime[] }) => void;
selectedDays: DateTime[];
};
};
}
const InlineDatepicker = templateOnlyComponent<InlineDatepickerSignature>();
export default InlineDatepicker;
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
InlineDatepicker: typeof InlineDatepicker;
}
}

View file

@ -0,0 +1,8 @@
{{! template-lint-disable require-input-label }}
<select class="language-select" {{on "change" this.handleChange}}>
{{#each-in this.locales as |localeKey localeName|}}
<option value={{localeKey}} selected={{eq localeKey this.currentLocale}}>
{{localeName}}
</option>
{{/each-in}}
</select>

View file

@ -1,49 +0,0 @@
import classic from 'ember-classic-decorator';
import { classNames, tagName } from '@ember-decorators/component';
import { computed } from '@ember/object';
import { inject as service } from '@ember/service';
import { readOnly } from '@ember/object/computed';
import Component from '@ember/component';
import localesMeta from 'croodle/locales/meta';
@classic
@tagName('select')
@classNames('language-select')
export default class LanguageSelect extends Component {
@service
intl;
@service
moment;
@service
powerCalendar;
@readOnly('intl.primaryLocale')
current;
@computed('intl.locales')
get locales() {
let currentLocale = this.intl.primaryLocale;
return Object.keys(localesMeta).map(function(locale) {
return {
id: locale,
selected: locale === currentLocale,
text: localesMeta[locale]
};
});
}
change() {
let locale = this.element.options[this.element.selectedIndex].value;
this.intl.set('locale', locale.includes('-') ? [locale, locale.split('-')[0]] : [locale]);
this.moment.changeLocale(locale);
this.powerCalendar.set('locale', locale);
if (window.localStorage) {
window.localStorage.setItem('locale', locale);
}
}
}

View file

@ -0,0 +1,38 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import localesMeta from 'croodle/locales/meta';
import { action } from '@ember/object';
import type IntlService from 'ember-intl/services/intl';
import type PowerCalendarService from 'ember-power-calendar/services/power-calendar';
export default class LanguageSelect extends Component {
@service declare intl: IntlService;
@service declare powerCalendar: PowerCalendarService;
get currentLocale() {
return this.intl.primaryLocale;
}
locales = localesMeta;
@action
handleChange(event: Event) {
const selectElement = event.target as HTMLSelectElement;
const locale = selectElement.value as keyof typeof this.locales;
this.intl.locale = locale.includes('-')
? [locale, locale.split('-')[0] as string]
: [locale];
this.powerCalendar.locale = locale;
if (window.localStorage) {
window.localStorage.setItem('locale', locale);
}
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
LanguageSelect: typeof LanguageSelect;
}
}

View file

@ -0,0 +1,5 @@
<span
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>

View file

@ -0,0 +1,13 @@
import templateOnlyComponent from '@ember/component/template-only';
interface LoadingSpinnerSignature {}
const LoadingSpinner = templateOnlyComponent<LoadingSpinnerSignature>();
export default LoadingSpinner;
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
LoadingSpinner: typeof LoadingSpinner;
}
}

View file

@ -0,0 +1,20 @@
import templateOnlyComponent from '@ember/component/template-only';
interface NextButtonSignature {
Args: {
Named: {
isPending?: boolean;
};
};
Element: HTMLButtonElement;
}
const NextButton = templateOnlyComponent<NextButtonSignature>();
export default NextButton;
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
NextButton: typeof NextButton;
}
}

View file

@ -1,134 +0,0 @@
import classic from 'ember-classic-decorator';
import { inject as service } from '@ember/service';
import { readOnly } from '@ember/object/computed';
import Component from '@ember/component';
import { get, computed } from '@ember/object';
import { isArray } from '@ember/array';
import { isPresent } from '@ember/utils';
import moment from 'moment';
const addArrays = function() {
let args = Array.prototype.slice.call(arguments);
let basis = args.shift();
if (!isArray(basis)) {
return [];
}
args.forEach(function(array) {
array.forEach(function(value, index) {
if (isPresent(value)) {
basis[index] = basis[index] + value;
}
});
});
return basis;
};
@classic
export default class PollEvaluationChart extends Component {
@service
intl;
@computed
get chartOptions() {
return {
legend: {
display: false
},
scales: {
xAxes: [{
stacked: true
}],
yAxes: [{
stacked: true,
ticks: {
callback(value) {
return `${value} %`;
},
max: 100,
min: 0
}
}]
},
tooltips: {
mode: 'label',
callbacks: {
label(tooltipItem, data) {
let { datasets } = data;
let { datasetIndex } = tooltipItem;
let { label } = datasets[datasetIndex];
let value = tooltipItem.yLabel;
return `${label}: ${value} %`;
}
}
}
}
}
@computed('users.[]', 'options.{[],each.title}', 'currentLocale')
get data() {
let labels = this.options.map((option) => {
let value = get(option, 'title');
if (!this.isFindADate) {
return value;
}
let hasTime = value.length > 10; // 'YYYY-MM-DD'.length === 10
let momentFormat = hasTime ? 'LLLL' : this.momentLongDayFormat;
let timezone = this.timezone;
let date = hasTime && isPresent(timezone) ? moment.tz(value, timezone) : moment(value);
date.locale(this.currentLocale);
return date.format(momentFormat);
});
let datasets = [];
let participants = this.users.length;
let yes = this.users.map(({ selections }) => {
return selections.map(({ type }) => type === 'yes' ? 1 : 0);
});
datasets.push({
label: this.intl.t('answerTypes.yes.label').toString(),
backgroundColor: 'rgba(151,187,205,0.5)',
borderColor: 'rgba(151,187,205,0.8)',
hoverBackgroundColor: 'rgba(151,187,205,0.75)',
hoverBorderColor: 'rgba(151,187,205,1)',
data: addArrays.apply(this, yes).map((value) => Math.round(value / participants * 100))
});
if (this.answerType === 'YesNoMaybe') {
let maybe = this.users.map(({ selections }) => {
return selections.map(({ type }) => type === 'maybe' ? 1 : 0);
});
datasets.push({
label: this.intl.t('answerTypes.maybe.label').toString(),
backgroundColor: 'rgba(220,220,220,0.5)',
borderColor: 'rgba(220,220,220,0.8)',
hoverBackgroundColor: 'rgba(220,220,220,0.75)',
hoverBorderColor: 'rgba(220,220,220,1)',
data: addArrays.apply(this, maybe).map((value) => Math.round(value / participants * 100))
});
}
return {
datasets,
labels
};
}
@readOnly('poll.answerType')
answerType;
@readOnly('intl.primaryLocale')
currentLocale;
@readOnly('poll.isFindADate')
isFindADate;
@readOnly('poll.options')
options;
@readOnly('poll.users')
users;
}

View file

@ -0,0 +1,86 @@
<div class="participants-table">
<table class="table" data-test-table-of="participants">
<thead>
{{#if @poll.hasTimes}}
<tr>
<th>
{{! column for name }}
</th>
{{#each-in this.optionsPerDay as |jsDate count|}}
{{!
@glint-ignore
We can be sure that count is a number because it is destructed from a
Map, which values are only numbers. But somehow Glint / TypeScript
is not sure about it.
}}
<th colspan={{count}}>
{{format-date jsDate dateStyle="full" timeZone=@timeZone}}
</th>
{{/each-in}}
</tr>
{{/if}}
<tr>
<th>
{{! column for name }}
</th>
{{#each @poll.options as |option|}}
<th>
{{#if (and @poll.isFindADate @poll.hasTimes)}}
{{#if option.hasTime}}
{{!
@glint-ignore
Narrowring is not working here correctly. Due to the only executing if
`option.hasTime` is `true`, we know that `option.jsDate` cannot be `null`.
But TypeScript does not support narrowing through a chain of getters
currently.
}}
{{! @glint-ignore }}{{! prettier-ignore }}
{{format-date option.jsDate
timeStyle="short"
timeZone=@timeZone
}}
{{/if}}
{{else if @poll.isFindADate}}
{{!
@glint-ignore
Narrowring is not working here correctly. Due to the only executing if
`option.hasTime` is `true`, we know that `option.jsDate` cannot be `null`.
But TypeScript does not support narrowing through a chain of getters
currently.
}}
{{format-date option.jsDate dateStyle="full" timeZone=@timeZone}}
{{else}}
{{option.title}}
{{/if}}
</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.usersSorted as |user|}}
<tr data-test-participant={{user.id}}>
<td data-test-value-for="name">
{{user.name}}
</td>
{{#each @poll.options as |option index|}}
{{#let (get user.selections index) as |selection|}}
<td
class={{selection.type}}
data-test-is-selection-cell
data-test-value-for={{option.title}}
>
{{#if selection.labelTranslation}}
{{t selection.labelTranslation}}
{{else}}
{{selection.label}}
{{/if}}
</td>
{{/let}}
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</div>

View file

@ -1,29 +0,0 @@
import classic from 'ember-classic-decorator';
import { readOnly } from '@ember/object/computed';
import Component from '@ember/component';
import { raw } from 'ember-awesome-macros';
import { groupBy, sort } from 'ember-awesome-macros/array';
@classic
export default class PollEvaluationParticipantsTable extends Component {
@readOnly('poll.hasTimes')
hasTimes;
@readOnly('poll.isFindADate')
isFindADate;
@readOnly('poll.isFreeText')
isFreeText;
@readOnly('poll.options')
options;
@groupBy('options', raw('day'))
optionsGroupedByDays;
@readOnly('poll.users')
users;
@sort('users', ['creationDate'])
usersSorted;
}

View file

@ -0,0 +1,52 @@
import Component from '@glimmer/component';
import type Poll from 'croodle/models/poll';
import { DateTime } from 'luxon';
export interface PollEvaluationParticipantsTableSignature {
Args: {
poll: Poll;
timeZone: string | undefined;
};
}
export default class PollEvaluationParticipantsTable extends Component<PollEvaluationParticipantsTableSignature> {
get optionsPerDay() {
const { poll } = this.args;
const optionsPerDay: Map<string, number> = new Map();
for (const option of poll.options) {
if (!option.day) {
throw new Error(
`Excepts all options to have a valid ISO8601 date string when using optionsPerDay getter`,
);
}
optionsPerDay.set(
option.day,
optionsPerDay.has(option.day)
? (optionsPerDay.get(option.day) as number) + 1
: 0,
);
}
return new Map(
Array.from(optionsPerDay.entries()).map(([dayString, count]) => [
DateTime.fromISO(dayString).toJSDate(),
count,
]),
);
}
get usersSorted() {
const { poll } = this.args;
return Array.from(poll.users).sort((a, b) =>
a.creationDate > b.creationDate ? 1 : -1,
);
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
PollEvaluationParticipantsTable: typeof PollEvaluationParticipantsTable;
}
}

View file

@ -1,23 +1,28 @@
{{!--
{{!
There must not be a line break between option text and "</strong>." cause otherwise
we will see a space between option string and following dot.
--}}
{{#if @isFindADate}}
{{! Need to disable block indentation rule cause there shouldn't be a space between date and dot }}
{{! template-lint-disable block-indentation }}
<strong class="best-option-value">
{{moment-format
@evaluationBestOption.option.title
(if @evaluationBestOption.option.hasTime "LLLL" @momentLongDayFormat)
locale=@currentLocale
timeZone=@timezone
}}
{{!
Checking `@evaluationBestOption.option.jsDate` is the same as checking `@isFindADate`.
If poll type is `FindADate` we can be sure that every option is a valid ISO861
string. Therefore `Option.jsDate` must be `true` by design. But Glint / TypeScript
does not understand that. Therefore we need to use the less readable form.
}}
{{#if @evaluationBestOption.option.jsDate}}
<strong data-test-best-option={{@evaluationBestOption.option.title}}>
{{format-date
@evaluationBestOption.option.jsDate
dateStyle="full"
timeStyle=(if @evaluationBestOption.option.hasTime "short" undefined)
timeZone=(if @timeZone @timeZone undefined)
}}</strong>.
{{! template-lint-enable block-indentation }}
{{else}}
<strong class="best-option-value">{{@evaluationBestOption.option.title}}</strong>.
<strong
data-test-best-option={{@evaluationBestOption.option.title}}
>{{@evaluationBestOption.option.title}}</strong>.
{{/if}}
<br>
<br />
{{#if @isFindADate}}
{{#if @evaluationBestOption.answers.yes}}

View file

@ -0,0 +1,24 @@
import templateOnlyComponent from '@ember/component/template-only';
import type { BestOption } from './poll-evaluation-summary';
interface PollEvaluationSummaryOptionSignature {
Args: {
Named: {
evaluationBestOption: BestOption;
isFindADate: boolean;
timeZone: string | undefined;
};
};
Element: HTMLButtonElement;
}
const PollEvaluationSummaryOption =
templateOnlyComponent<PollEvaluationSummaryOptionSignature>();
export default PollEvaluationSummaryOption;
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
PollEvaluationSummaryOption: typeof PollEvaluationSummaryOption;
}
}

View file

@ -0,0 +1,62 @@
<div class="evaluation-summary">
<h2>
{{t "poll.evaluation.label"}}
</h2>
<p class="participants">
{{t "poll.evaluation.participants" count=@poll.users.length}}
</p>
<p class="best-options">
{{#if @poll.isFindADate}}
{{t
"poll.evaluation.bestOption.label.findADate"
count=this.bestOptions.length
}}
{{else}}
{{t
"poll.evaluation.bestOption.label.makeAPoll"
count=this.bestOptions.length
}}
{{/if}}
{{#if (gt this.bestOptions.length 1)}}
<ul>
{{#each this.bestOptions as |evaluationBestOption|}}
<li>
<PollEvaluationSummaryOption
@evaluationBestOption={{evaluationBestOption}}
@isFindADate={{@poll.isFindADate}}
@timeZone={{@timeZone}}
/>
</li>
{{/each}}
</ul>
{{else}}
<PollEvaluationSummaryOption
{{!
@glint-ignore
We can be sure that `this.bestOptions` contains at least one item
as a poll must always have at least one option.
}}
@evaluationBestOption={{get this.bestOptions 0}}
@isFindADate={{@poll.isFindADate}}
@timeZone={{@timeZone}}
/>
{{/if}}
</p>
<p class="last-participation">
{{#if this.lastParticipationAt}}
{{t
"poll.evaluation.lastParticipation"
ago=(format-date-relative this.lastParticipationAt)
}}
{{else}}
{{!
No need for the else block as user cannot enter evaluation page if
no one participated in the poll yet.
}}
{{/if}}
</p>
</div>

View file

@ -1,96 +0,0 @@
import classic from 'ember-classic-decorator';
import { classNames } from '@ember-decorators/component';
import { computed } from '@ember/object';
import { inject as service } from '@ember/service';
import { readOnly, max, mapBy, gt } from '@ember/object/computed';
import Component from '@ember/component';
import { copy } from '@ember/object/internals';
import { isEmpty } from '@ember/utils';
@classic
@classNames('evaluation-summary')
export default class PollEvaluationSummary extends Component {
@service
intl;
@computed('users.[]')
get bestOptions() {
// can not evaluate answer type free text
if (this.get('poll.isFreeText')) {
return undefined;
}
// can not evaluate a poll without users
if (isEmpty(this.users)) {
return undefined;
}
let answers = this.poll.answers.reduce((answers, answer) => {
answers[answer.get('type')] = 0;
return answers;
}, {});
let evaluation = this.poll.options.map((option) => {
return {
answers: copy(answers),
option,
score: 0
};
});
let bestOptions = [];
this.users.forEach((user) => {
user.selections.forEach(({ type }, i) => {
evaluation[i].answers[type]++;
switch (type) {
case 'yes':
evaluation[i].score += 2;
break;
case 'maybe':
evaluation[i].score += 1;
break;
case 'no':
evaluation[i].score -= 2;
break;
}
});
});
evaluation.sort((a, b) => b.score - a.score);
let bestScore = evaluation[0].score;
for (let i = 0; i < evaluation.length; i++) {
if (
bestScore === evaluation[i].score
) {
bestOptions.push(
evaluation[i]
);
} else {
break;
}
}
return bestOptions;
}
@readOnly('intl.primaryLocale')
currentLocale;
@gt('bestOptions.length', 1)
multipleBestOptions;
@max('participationDates')
lastParticipationAt;
@mapBy('users', 'creationDate')
participationDates;
@readOnly('users.length')
participantsCount;
@readOnly('poll.users')
users;
}

View file

@ -0,0 +1,124 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import type IntlService from 'ember-intl/services/intl';
import type Option from 'croodle/models/option';
import type User from 'croodle/models/user';
import type { Answer } from 'croodle/utils/answers-for-answer-type';
import type Poll from 'croodle/models/poll';
export interface PollEvaluationSummarySignature {
Args: {
poll: Poll;
timeZone: string | undefined;
};
}
export interface BestOption {
answers: Record<'yes' | 'no' | 'maybe', number>;
option: Option;
score: number;
}
export default class PollEvaluationSummary extends Component<PollEvaluationSummarySignature> {
@service declare intl: IntlService;
get bestOptions(): BestOption[] | null {
const { poll } = this.args;
const { isFreeText, options, users } = poll;
// can not evaluate answer type free text
if (isFreeText) {
return null;
}
// can not evaluate a poll without users
if (users.length < 1) {
return null;
}
const answers = poll.answers.reduce(
(answers, answer: Answer) => {
answers[answer.type] = 0;
return answers;
},
{} as Record<'yes' | 'no' | 'maybe', number>,
);
const evaluation: BestOption[] = options.map((option: Option) => {
return {
answers: { ...answers },
option,
score: 0,
};
});
users.forEach((user: User) => {
user.selections.forEach(({ type }, i) => {
if (!type) {
// type may be undefined if poll does not force an answer to all options
return;
}
const evaluationForOption = evaluation[i];
if (evaluationForOption === undefined) {
throw new Error(
'Mismatch between number of options in poll and selections for user',
);
}
if (type !== 'yes' && type !== 'no' && type !== 'maybe') {
throw new Error(
`Encountered not supported type of user selection: ${type}`,
);
}
evaluationForOption.answers[type]++;
switch (type) {
case 'yes':
evaluation[i]!.score += 2;
break;
case 'maybe':
evaluation[i]!.score += 1;
break;
case 'no':
evaluation[i]!.score -= 2;
break;
}
});
});
evaluation.sort((a, b) => b.score - a.score);
const bestOptions = [];
const bestScore = evaluation[0]!.score;
for (const evaluationForOption of evaluation) {
if (evaluationForOption.score === bestScore) {
bestOptions.push(evaluationForOption);
} else {
break;
}
}
return bestOptions;
}
get lastParticipationAt() {
const { users } = this.args.poll;
let lastParticipationAt = null;
for (const { creationDate } of users) {
if (lastParticipationAt === null || creationDate >= lastParticipationAt) {
lastParticipationAt = creationDate;
}
}
return lastParticipationAt;
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
PollEvaluationSummary: typeof PollEvaluationSummary;
}
}

View file

@ -0,0 +1,27 @@
<BsButton
@type="primary"
{{!
Due to a bug in Ember, conditional modifiers cannot be used with the "on"
modifier. Need to always apply the modifier and fallback to a noop function
(returned by noop helper) instead of only applying the modifier when needed.
See https://github.com/emberjs/ember.js/issues/19869.
}}
{{on "click" (if @onClick @onClick (noop))}}
class="cr-steps-bottom-nav__button cr-steps-bottom-nav__next-button next"
type="submit"
...attributes
>
<span class="cr-steps-bottom-nav__label">
{{#if (has-block)}}
{{yield}}
{{else}}
{{t "action.save"}}
{{/if}}
</span>
{{#if @isPending}}
<LoadingSpinner />
{{else}}
<span class="cr-steps-bottom-nav__icon oi oi-circle-check"></span>
{{/if}}
</BsButton>

View file

@ -0,0 +1,24 @@
import templateOnlyComponent from '@ember/component/template-only';
interface SaveButtonSignature {
Args: {
Named: {
isPending: boolean;
onClick?: () => void;
};
};
Blocks: {
default: [];
};
Element: HTMLButtonElement;
}
const SaveButton = templateOnlyComponent<SaveButtonSignature>();
export default SaveButton;
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
SaveButton: typeof SaveButton;
}
}

View file

@ -0,0 +1,32 @@
<div class="box poll-link cr-poll-link">
<p>{{t "poll.share.title"}}</p>
<p class="link cr-poll-link__link">
<small>
<code class="cr-poll-link__url" data-test-poll-url>{{this.pollUrl}}</code>
</small>
<CopyButton
@text={{this.pollUrl}}
@onSuccess={{this.showCopySuccessMessage}}
class="btn btn-secondary cr-poll-link__copy-btn btn-sm"
data-test-button="copy-link"
>
{{t "poll.link.copy-label"}}&nbsp;
<span
class="oi oi-clipboard"
title={{t "poll.link.copy-label"}}
aria-hidden="true"
></span>
<BsTooltip
@placement="bottom"
@triggerEvents="click"
data-test-tooltip="copied"
>
{{t "poll.link.copied"}}
</BsTooltip>
</CopyButton>
</p>
<small class="text-muted">
{{t "poll.share.notice"}}
</small>
</div>

View file

@ -0,0 +1,40 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import type RouterService from '@ember/routing/router-service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class SharePollUrlComponent extends Component {
@service declare router: RouterService;
@tracked shouldShowCopySuccessMessage = false;
get pollUrl() {
// RouterService.currentURL is relative to the webserver's
// directory from which Croodle is served.
const relativeUrl = this.router.currentURL;
if (!relativeUrl) {
throw new Error('Relative URL is `null`. Cannot calculate poll URL.');
}
// The URL under which Croodle is served, is not known at
// build-time. But we can construct it ourself, by parsing
// window.location.href and replacing the hash part.
const absoluteUrl = new URL(window.location.href);
absoluteUrl.hash = relativeUrl;
return absoluteUrl.toString();
}
@action
showCopySuccessMessage() {
this.shouldShowCopySuccessMessage = true;
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
SharePollUrl: typeof SharePollUrlComponent;
}
}

14
app/config/environment.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
/**
* Type declarations for
* import config from 'croodle/config/environment'
*/
declare const config: {
environment: string;
modulePrefix: string;
podModulePrefix: string;
locationType: 'history' | 'hash' | 'none';
rootURL: string;
APP: Record<string, unknown>;
};
export default config;

View file

@ -1,61 +0,0 @@
import { inject as service } from '@ember/service';
import { action, computed } from '@ember/object';
import Controller from '@ember/controller';
export default class CreateController extends Controller {
@service
router;
@computed('model.pollType', 'visitedSteps')
get canEnterMetaStep() {
return this.visitedSteps.has('meta') && this.model.pollType;
}
@computed('model.title', 'visitedSteps')
get canEnterOptionsStep() {
let { title } = this.model;
return this.visitedSteps.has('options') &&
typeof title === 'string' && title.length >= 2;
}
@computed('model.options.[]', 'visitedSteps')
get canEnterOptionsDatetimeStep() {
return this.visitedSteps.has('options-datetime') && this.model.options.length >= 1;
}
@computed('model.options.[]', 'visitedSteps')
get canEnterSettingsStep() {
return this.visitedSteps.has('settings') && this.model.options.length >= 1;
}
@computed('model.pollType')
get isFindADate() {
return this.model.pollType === 'FindADate';
}
@action
updateVisitedSteps() {
let { currentRouteName } = this.router;
// currentRouteName might not be defined in some edge cases
if (!currentRouteName) {
return;
}
let step = currentRouteName.split('.').pop();
this.visitedSteps.add(step);
// as visitedSteps is a Set must notify about changes manually
this.notifyPropertyChange('visitedSteps');
}
listenForStepChanges() {
this.set('visitedSteps', new Set());
this.router.on('routeDidChange', this.updateVisitedSteps);
}
clearListenerForStepChanges() {
this.router.off('routeDidChange', this.updateVisitedSteps);
}
}

73
app/controllers/create.ts Normal file
View file

@ -0,0 +1,73 @@
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import Controller from '@ember/controller';
import { TrackedSet } from 'tracked-built-ins';
import type RouterService from '@ember/routing/router-service';
import type { CreateRouteModel } from 'croodle/routes/create';
export default class CreateController extends Controller {
@service declare router: RouterService;
declare model: CreateRouteModel;
visitedSteps = new TrackedSet();
get canEnterMetaStep() {
return this.visitedSteps.has('meta') && this.model.pollType;
}
get canEnterOptionsStep() {
const { title } = this.model;
return (
this.visitedSteps.has('options') &&
typeof title === 'string' &&
title.length >= 2
);
}
get canEnterOptionsDatetimeStep() {
return (
this.visitedSteps.has('options-datetime') &&
this.model.dateOptions.size >= 1
);
}
get canEnterSettingsStep() {
const { model, visitedSteps } = this;
const { dateOptions, freetextOptions, pollType } = model;
const options = pollType === 'FindADate' ? dateOptions : freetextOptions;
return visitedSteps.has('settings') && options.size >= 1;
}
get isFindADate() {
return this.model.pollType === 'FindADate';
}
@action
updateVisitedSteps() {
const { currentRouteName } = this.router;
// currentRouteName might not be defined in some edge cases
if (!currentRouteName) {
return;
}
const step = currentRouteName.split('.').pop();
this.visitedSteps.add(step);
}
@action transitionTo(route: string) {
this.router.transitionTo(route);
}
listenForStepChanges() {
this.set('visitedSteps', new TrackedSet());
this.router.on('routeDidChange', this.updateVisitedSteps);
}
clearListenerForStepChanges() {
this.router.off('routeDidChange', this.updateVisitedSteps);
}
}

View file

@ -1,42 +0,0 @@
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { alias } from '@ember/object/computed';
import Controller from '@ember/controller';
import {
validator, buildValidations
}
from 'ember-cp-validations';
const Validations = buildValidations({
pollType: [
validator('presence', {
presence: true,
dependentKeys: ['model.intl.locale']
}),
validator('inclusion', {
in: ['FindADate', 'MakeAPoll'],
dependentKeys: ['model.intl.locale']
})
]
});
export default class CreateIndex extends Controller.extend(Validations) {
@service
intl;
@alias('model.pollType')
pollType;
@action
submit() {
if (this.get('validations.isValid')) {
this.transitionToRoute('create.meta');
}
}
init() {
super.init(...arguments);
this.intl.locale;
}
}

View file

@ -1,50 +0,0 @@
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { alias } from '@ember/object/computed';
import Controller from '@ember/controller';
import {
validator, buildValidations
}
from 'ember-cp-validations';
const Validations = buildValidations({
title: [
validator('presence', {
presence: true,
dependentKeys: ['model.intl.locale']
}),
validator('length', {
min: 2,
dependentKeys: ['model.intl.locale']
})
]
});
export default class CreateMetaController extends Controller.extend(Validations) {
@service
intl;
@alias('model.description')
description;
@alias('model.title')
title;
init() {
super.init(...arguments);
this.get('intl.locale');
}
@action
previousPage() {
this.transitionToRoute('create.index');
}
@action
submit() {
if (this.get('validations.isValid')) {
this.transitionToRoute('create.options');
}
}
}

View file

@ -1,47 +0,0 @@
import classic from 'ember-classic-decorator';
import { action } from '@ember/object';
import { alias } from '@ember/object/computed';
import Controller from '@ember/controller';
import moment from 'moment';
@classic
export default class CreateOptionsDatetimeController extends Controller {
@action
nextPage() {
this.normalizeOptions();
this.transitionToRoute('create.settings');
}
@action
previousPage() {
this.transitionToRoute('create.options');
}
normalizeOptions() {
const options = this.options;
// remove all days from options which haven't a time but there is atleast
// one option with time for that day
const daysWithTime = options.map((option) => {
if (moment(option.get('title'), 'YYYY-MM-DD', true).isValid()) {
return null;
} else {
return moment(option.get('title')).format('YYYY-MM-DD');
}
}).uniq().filter((option) => option !== null);
const removeObjects = options.filter((option) => {
return daysWithTime.indexOf(option.get('title')) !== -1;
});
options.removeObjects(
removeObjects
);
// sort options
// ToDo: Find a better way without reseting the options
this.set('options', options.sortBy('title'));
}
@alias('model.options')
options;
}

View file

@ -1,24 +0,0 @@
import classic from 'ember-classic-decorator';
import { action } from '@ember/object';
import { alias } from '@ember/object/computed';
import Controller from '@ember/controller';
@classic
export default class CreateOptionsController extends Controller {
@action
nextPage() {
if (this.isFindADate) {
this.transitionToRoute('create.options-datetime');
} else {
this.transitionToRoute('create.settings');
}
}
@action
previousPage() {
this.transitionToRoute('create.meta');
}
@alias('model.isFindADate')
isFindADate;
}

View file

@ -1,146 +0,0 @@
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import Controller from '@ember/controller';
import { isPresent } from '@ember/utils';
import { action, computed } from '@ember/object';
import answersForAnswerType from 'croodle/utils/answers-for-answer-type';
import {
validator, buildValidations
}
from 'ember-cp-validations';
import moment from 'moment';
const Validations = buildValidations({
anonymousUser: validator('presence', {
presence: true,
dependentKeys: ['model.intl.locale']
}),
answerType: [
validator('presence', {
presence: true,
dependentKeys: ['model.intl.locale']
}),
validator('inclusion', {
in: ['YesNo', 'YesNoMaybe', 'FreeText'],
dependentKeys: ['model.intl.locale']
})
],
forceAnswer: validator('presence', true)
});
export default class CreateSettings extends Controller.extend(Validations) {
@service
encryption;
@service
intl;
@alias('model.anonymousUser')
anonymousUser;
@alias('model.answerType')
answerType;
@computed
get answerTypes() {
return [
{ id: 'YesNo', labelTranslation: 'answerTypes.yesNo.label' },
{ id: 'YesNoMaybe', labelTranslation: 'answerTypes.yesNoMaybe.label' },
{ id: 'FreeText', labelTranslation: 'answerTypes.freeText.label' },
];
}
@computed('model.expirationDate')
get expirationDuration() {
// TODO: must be calculated based on model.expirationDate
return 'P3M';
}
set expirationDuration(value) {
this.set(
'model.expirationDate',
isPresent(value) ? moment().add(moment.duration(value)).toISOString(): ''
);
return value;
}
@computed
get expirationDurations() {
return [
{ id: 'P7D', labelTranslation: 'create.settings.expirationDurations.P7D' },
{ id: 'P1M', labelTranslation: 'create.settings.expirationDurations.P1M' },
{ id: 'P3M', labelTranslation: 'create.settings.expirationDurations.P3M' },
{ id: 'P6M', labelTranslation: 'create.settings.expirationDurations.P6M' },
{ id: 'P1Y', labelTranslation: 'create.settings.expirationDurations.P1Y' },
{ id: '', labelTranslation: 'create.settings.expirationDurations.never' },
];
}
@alias('model.forceAnswer')
forceAnswer;
@action
previousPage() {
let { isFindADate } = this.model;
if (isFindADate) {
this.transitionToRoute('create.options-datetime');
} else {
this.transitionToRoute('create.options');
}
}
@action
async submit() {
if (!this.validations.isValid) {
return;
}
let poll = this.model;
// set timezone if there is atleast one option with time
if (
poll.isFindADate &&
poll.options.any(({ title }) => {
return !moment(title, 'YYYY-MM-DD', true).isValid();
})
) {
this.set('model.timezone', moment.tz.guess());
}
// save poll
try {
await poll.save();
} catch(err) {
this.flashMessages.danger('error.poll.savingFailed');
throw err;
}
try {
// reload as workaround for bug: duplicated records after save
await poll.reload();
// redirect to new poll
await this.transitionToRoute('poll', poll, {
queryParams: {
encryptionKey: this.encryption.key,
},
});
} catch(err) {
// TODO: show feedback to user
throw err;
}
}
@action
updateAnswerType(answerType) {
this.set('model.answerType', answerType);
this.set('model.answers', answersForAnswerType(answerType));
}
init() {
super.init(...arguments);
this.intl.locale;
}
}

View file

@ -1,5 +0,0 @@
import classic from 'ember-classic-decorator';
import Controller from '@ember/controller';
@classic
export default class ErrorController extends Controller {}

View file

@ -1,16 +0,0 @@
import classic from 'ember-classic-decorator';
import { computed } from '@ember/object';
import { equal } from '@ember/object/computed';
import Controller from '@ember/controller';
import sjcl from 'sjcl';
@classic
export default class PollErrorController extends Controller {
@computed('model')
get decryptionFailed() {
return this.model instanceof sjcl.exception.corrupt;
}
@equal('model.errors.firstObject.status', '404')
notFound;
}

View file

@ -0,0 +1,13 @@
import Controller from '@ember/controller';
import sjcl from 'sjcl';
import { NotFoundError } from '../utils/api';
export default class PollErrorController extends Controller {
get decryptionFailed() {
return this.model instanceof sjcl.exception.corrupt;
}
get notFound() {
return this.model instanceof NotFoundError;
}
}

View file

@ -1,110 +0,0 @@
import { inject as service } from '@ember/service';
import { readOnly } from '@ember/object/computed';
import Controller from '@ember/controller';
import { isPresent, isEmpty } from '@ember/utils';
import { action, computed } from '@ember/object';
import { observes } from '@ember-decorators/object';
import moment from 'moment';
export default class PollController extends Controller {
@service
encryption;
@service
flashMessages;
@service
intl;
@service
router;
queryParams = ['encryptionKey'];
encryptionKey = '';
timezoneChoosen = false;
useLocalTimezone = false;
@readOnly('intl.primaryLocale')
currentLocale;
@computed('currentLocale')
get momentLongDayFormat() {
let currentLocale = this.currentLocale;
return moment.localeData(currentLocale)
.longDateFormat('LLLL')
.replace(
moment.localeData(currentLocale).longDateFormat('LT'), '')
.trim();
}
@readOnly('model')
poll;
@computed('router.currentURL', 'encryptionKey')
get pollUrl() {
return window.location.href;
}
@computed('poll.expirationDate')
get showExpirationWarning() {
let expirationDate = this.poll.expirationDate;
if (isEmpty(expirationDate)) {
return false;
}
return moment().add(2, 'weeks').isAfter(moment(expirationDate));
}
/*
* return true if current timezone differs from timezone poll got created with
*/
@computed('poll.timezone')
get timezoneDiffers() {
let modelTimezone = this.poll.timezone;
return isPresent(modelTimezone) && moment.tz.guess() !== modelTimezone;
}
@computed('timezoneDiffers', 'timezoneChoosen')
get mustChooseTimezone() {
return this.timezoneDiffers && !this.timezoneChoosen;
}
@computed('useLocalTimezone')
get timezone() {
return this.useLocalTimezone ? undefined : this.poll.timezone;
}
@action
linkAction(type) {
let flashMessages = this.flashMessages;
switch (type) {
case 'copied':
flashMessages.success(`poll.link.copied`);
break;
case 'selected':
flashMessages.info(`poll.link.selected`);
break;
}
}
@action
useLocalTimezone() {
this.set('useLocalTimezone', true);
this.set('timezoneChoosen', true);
}
// TODO: Remove this code. It's spooky.
@observes('encryptionKey')
preventEncryptionKeyChanges() {
if (
!isEmpty(this.encryption.key) &&
this.encryptionKey !== this.encryption.key
) {
// work-a-round for url not being updated
window.location.hash = window.location.hash.replace(this.encryptionKey, this.encryption.key);
this.set('encryptionKey', this.encryption.key);
}
}
}

68
app/controllers/poll.ts Normal file
View file

@ -0,0 +1,68 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { isPresent, isEmpty } from '@ember/utils';
import { action } from '@ember/object';
import { DateTime } from 'luxon';
import { tracked } from '@glimmer/tracking';
import type IntlService from 'ember-intl/services/intl';
import type RouterService from '@ember/routing/router-service';
import type { PollRouteModel } from 'croodle/routes/poll';
export default class PollController extends Controller {
@service declare intl: IntlService;
@service declare router: RouterService;
declare model: PollRouteModel;
queryParams = ['encryptionKey'];
encryptionKey = '';
@tracked timezoneChoosen = false;
@tracked shouldUseLocalTimezone = false;
get showExpirationWarning() {
const { model: poll } = this;
const { expirationDate } = poll;
if (isEmpty(expirationDate)) {
return false;
}
return (
DateTime.local().plus({ weeks: 2 }) >= DateTime.fromISO(expirationDate)
);
}
/*
* return true if current timezone differs from timezone poll got created with
*/
get timezoneDiffers() {
const { model: poll } = this;
const { timezone: pollTimezone } = poll;
return (
isPresent(pollTimezone) &&
Intl.DateTimeFormat().resolvedOptions().timeZone !== pollTimezone
);
}
get mustChooseTimezone() {
return this.timezoneDiffers && !this.timezoneChoosen;
}
get timezone() {
const { model: poll, shouldUseLocalTimezone } = this;
return shouldUseLocalTimezone || !poll.timezone ? undefined : poll.timezone;
}
@action
useLocalTimezone() {
this.shouldUseLocalTimezone = true;
this.timezoneChoosen = true;
}
@action
usePollTimezone() {
this.timezoneChoosen = true;
}
}

View file

@ -1,108 +0,0 @@
import classic from 'ember-classic-decorator';
import { computed } from '@ember/object';
import { inject as service } from '@ember/service';
import { readOnly, not, gt, and } from '@ember/object/computed';
import Controller, { inject as controller } from '@ember/controller';
@classic
export default class PollEvaluationController extends Controller {
@readOnly('intl.primaryLocale')
currentLocale;
@readOnly('poll.hasTimes')
hasTimes;
@service
intl;
@readOnly('pollController.momentLongDayFormat')
momentLongDayFormat;
@readOnly('model')
poll;
@controller('poll')
pollController;
@readOnly('pollController.timezone')
timezone;
@readOnly('poll.users')
users;
/*
* evaluates poll data
* if free text answers are allowed evaluation is disabled
*/
@computed('users.[]')
get evaluation() {
if (!this.isEvaluable) {
return [];
}
let evaluation = [];
let options = [];
let lookup = [];
// init options array
this.poll.options.forEach((option, index) => {
options[index] = 0;
});
// init array of evalutation objects
// create object for every possible answer
this.poll.answers.forEach((answer) => {
evaluation.push({
id: answer.label,
label: answer.label,
options: [...options],
});
});
// create object for no answer if answers are not forced
if (!this.poll.forceAnswer) {
evaluation.push({
id: null,
label: 'no answer',
options: [...options],
});
}
// create lookup array
evaluation.forEach(function(value, index) {
lookup[value.id] = index;
});
// loop over all users
this.poll.users.forEach((user) => {
// loop over all selections of the user
user.selections.forEach(function(selection, optionIndex) {
let answerIndex;
// get answer index by lookup array
if (typeof lookup[selection.value.label] === 'undefined') {
answerIndex = lookup[null];
} else {
answerIndex = lookup[selection.get('value.label')];
}
// increment counter
try {
evaluation[answerIndex].options[optionIndex]++;
} catch (e) {
// ToDo: Throw an error
}
});
});
return evaluation;
}
@gt('poll.users.length', 0)
hasUsers;
@not('poll.isFreeText')
isNotFreeText;
@and('hasUsers', 'isNotFreeText')
isEvaluable;
}

View file

@ -0,0 +1,21 @@
import Controller, { inject as controller } from '@ember/controller';
import { inject as service } from '@ember/service';
import type IntlService from 'ember-intl/services/intl';
import type PollController from '../poll';
import type { PollEvaluationRouteModel } from 'croodle/routes/poll/evaluation';
export default class PollEvaluationController extends Controller {
@service declare intl: IntlService;
@controller('poll') declare pollController: PollController;
declare model: PollEvaluationRouteModel;
get isEvaluable() {
const { model: poll } = this;
const { isFreeText, users } = poll;
const hasUsers = users.length > 0;
return hasUsers && !isFreeText;
}
}

View file

@ -1,240 +0,0 @@
import classic from 'ember-classic-decorator';
import { inject as service } from '@ember/service';
import { not, readOnly } from '@ember/object/computed';
import Controller, { inject as controller } from '@ember/controller';
import { getOwner } from '@ember/application';
import { isPresent, isEmpty } from '@ember/utils';
import EmberObject, { computed } from '@ember/object';
import {
validator, buildValidations
}
from 'ember-cp-validations';
import moment from 'moment';
import config from 'croodle/config/environment';
const validCollection = function(collection) {
// return false if any object in collection is inValid
return !collection.any((object) => {
return object.get('validations.isInvalid');
});
};
const Validations = buildValidations({
name: [
validator('presence', {
presence: true,
disabled: readOnly('model.anonymousUser'),
dependentKeys: ['model.intl.locale']
}),
validator('unique', {
parent: 'poll',
attributeInParent: 'users',
dependentKeys: ['model.poll.users.[]', 'model.poll.users.@each.name', 'model.intl.locale'],
disable: readOnly('model.anonymousUser'),
messageKey: 'errors.uniqueName',
ignoreNewRecords: true,
})
],
selections: [
validator('collection', true),
// all selection objects must be valid
// if forceAnswer is true in poll settings
validator(validCollection, {
dependentKeys: ['model.forceAnswer', 'model.selections.[]', 'model.selections.@each.value', 'model.intl.locale']
})
]
});
const SelectionValidations = buildValidations({
value: validator('presence', {
presence: true,
disabled: not('model.forceAnswer'),
messageKey: computed('model.isFreeText', function() {
return this.get('model.isFreeText') ? 'errors.present' : 'errors.answerRequired';
}),
dependentKeys: ['model.intl.locale']
})
});
@classic
class SelectionObject extends EmberObject.extend(SelectionValidations) {
@service intl;
value = null;
init() {
super.init(...arguments);
// current locale needs to be consumed in order to be observeable
// for localization of validation messages
this.intl.locale;
}
}
export default Controller.extend(Validations, {
actions: {
async submit() {
if (!this.get('validations.isValid')) {
return;
}
let poll = this.poll;
let selections = this.selections.map(({ value }) => {
if (value === null) {
return {};
}
if (this.isFreeText) {
return {
label: value,
};
}
// map selection to answer if it's not freetext
let answer = poll.answers.findBy('type', value);
let { icon, label, labelTranslation, type } = answer;
return {
icon,
label,
labelTranslation,
type,
};
});
let user = this.store.createRecord('user', {
creationDate: new Date(),
name: this.name,
poll,
selections,
version: config.APP.version,
});
this.set('newUserRecord', user);
await this.actions.save.bind(this)();
},
async save() {
let user = this.newUserRecord;
try {
await user.save();
this.set('savingFailed', false);
} catch (error) {
// couldn't save user model
this.set('savingFailed', true);
return;
}
// reset form
this.set('name', '');
this.selections.forEach((selection) => {
selection.set('value', null);
});
this.transitionToRoute('poll.evaluation', this.model, {
queryParams: { encryptionKey: this.encryption.key }
});
}
},
anonymousUser: readOnly('poll.anonymousUser'),
currentLocale: readOnly('intl.locale'),
encryption: service(),
forceAnswer: readOnly('poll.forceAnswer'),
intl: service(),
init() {
this._super(...arguments);
// current locale needs to be consumed in order to be observeable
// for localization of validation messages
this.intl.locale;
},
isFreeText: readOnly('poll.isFreeText'),
isFindADate: readOnly('poll.isFindADate'),
momentLongDayFormat: readOnly('pollController.momentLongDayFormat'),
name: '',
options: readOnly('poll.options'),
poll: readOnly('model'),
pollController: controller('poll'),
possibleAnswers: computed('poll.answers', function() {
return this.get('poll.answers').map((answer) => {
const owner = getOwner(this);
const AnswerObject = EmberObject.extend({
icon: answer.get('icon'),
type: answer.get('type')
});
if (!isEmpty(answer.get('labelTranslation'))) {
return AnswerObject.extend({
intl: service(),
label: computed('intl.locale', function() {
return this.intl.t(this.labelTranslation);
}),
labelTranslation: answer.get('labelTranslation'),
}).create(owner.ownerInjection());
} else {
return AnswerObject.extend({
label: answer.get('label')
});
}
});
}),
savingFailed: false,
selections: computed('options', 'pollController.dates', function() {
let options = this.options;
let isFindADate = this.isFindADate;
let lastDate;
return options.map((option) => {
let labelValue;
let momentFormat;
let value = option.get('title');
// format label
if (isFindADate) {
let hasTime = value.length > 10; // 'YYYY-MM-DD'.length === 10
let timezone = this.timezone;
let date = isPresent(timezone) ? moment.tz(value, timezone) : moment(value);
if (hasTime && lastDate && date.format('YYYY-MM-DD') === lastDate.format('YYYY-MM-DD')) {
labelValue = value;
// do not repeat dates for different times
momentFormat = 'LT';
} else {
labelValue = value;
momentFormat = hasTime ? 'LLLL' : 'day';
lastDate = date;
}
} else {
labelValue = value;
}
// https://github.com/offirgolan/ember-cp-validations#basic-usage---objects
// To lookup validators, container access is required which can cause an issue with Object creation
// if the object is statically imported. The current fix for this is as follows.
let owner = getOwner(this);
return SelectionObject.create(owner.ownerInjection(), {
labelValue,
momentFormat,
// forceAnswer and isFreeText must be included in model
// cause otherwise validations can't depend on it
forceAnswer: this.forceAnswer,
isFreeText: this.isFreeText,
});
});
}),
timezone: readOnly('pollController.timezone')
});

View file

@ -0,0 +1,107 @@
import Controller, { inject as controller } from '@ember/controller';
import User from '../../models/user';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import type RouterService from '@ember/routing/router-service';
import type PollController from '../poll';
import type { PollParticipationRouteModel } from 'croodle/routes/poll/participation';
import type Poll from 'croodle/models/poll';
import type { SelectionInput } from 'croodle/models/selection';
export default class PollParticipationController extends Controller {
@service declare router: RouterService;
@controller('poll') declare pollController: PollController;
declare model: PollParticipationRouteModel;
@tracked name = '';
@tracked savingFailed = false;
newUserData: {
name: string | null;
poll: Poll;
selections: SelectionInput[];
} | null = null;
@action
async submit() {
const { formData, poll } = this.model;
const { name } = formData;
const { answers, isFreeText } = poll;
const selections = formData.selections.map(({ value }) => {
if (value === null) {
return {};
}
if (isFreeText) {
return {
label: value,
};
}
// map selection to answer if it's not freetext
const answer = answers.find(({ type }) => type === value);
if (!answer) {
throw new Error('Mapping selection to answer failed');
}
const { icon, labelTranslation, type } = answer;
return {
icon,
labelTranslation,
type,
};
});
this.newUserData = {
name,
poll,
selections,
};
await this.save();
}
@action
async save() {
const { model, newUserData: userData } = this;
const { poll } = model;
// As know that the route is `poll.participation`, which means that there
// is a parent `poll` for sure.
const { encryptionKey } = this.router.currentRoute?.parent?.queryParams as {
encryptionKey: string;
};
if (!userData) {
throw new Error(
'save method called before submit method has set the user data',
);
}
if (!encryptionKey) {
throw new Error('Can not lookup encryption key');
}
try {
await User.create(userData, encryptionKey);
this.savingFailed = false;
} catch (error) {
// couldn't save user model
this.savingFailed = true;
return;
}
this.router.transitionTo('poll.evaluation', poll.id, {
queryParams: { encryptionKey },
});
}
@action
resetSavingStatus() {
this.savingFailed = false;
}
}

View file

@ -1,2 +1 @@
export default {
};
export default {};

View file

@ -0,0 +1,35 @@
import Helper from '@ember/component/helper';
import { DateTime } from 'luxon';
import { inject as service } from '@ember/service';
import type IntlService from 'ember-intl/services/intl';
type Positional = [date: Date | string];
export interface FormatDateRelativeHelperSignature {
Args: {
Positional: Positional;
};
Return: string;
}
export default class FormatDateRelative extends Helper<FormatDateRelativeHelperSignature> {
@service declare intl: IntlService;
compute([dateOrIsoString]: Positional) {
const isoString =
dateOrIsoString instanceof Date
? dateOrIsoString.toISOString()
: dateOrIsoString;
return DateTime.fromISO(isoString).toRelative({
locale: this.intl.primaryLocale,
padding: 1000,
})!;
}
}
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
'format-date-relative': typeof FormatDateRelative;
}
}

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