Browse Source

Number of improvements and validations.

Syfaro 10 months ago
parent
commit
d5dc51e768
5 changed files with 354 additions and 71 deletions
  1. 126
    33
      index.php
  2. 47
    0
      install.sql
  3. 121
    36
      peppershrike.js
  4. 5
    2
      templates/index.html
  5. 55
    0
      themes/syfaro.css

+ 126
- 33
index.php View File

@@ -11,12 +11,15 @@ ORM::configure("sqlite:" . __DIR__ . "/db.sqlite");
11 11
  * @property string $username
12 12
  * @property string $password
13 13
  */
14
-class User extends Model {
15
-    public static function hash_password($password) {
14
+class User extends Model
15
+{
16
+    public static function hash_password($password)
17
+    {
16 18
         return password_hash($password, PASSWORD_BCRYPT);
17 19
     }
18 20
 
19
-    public function verify_password($password) {
21
+    public function verify_password($password)
22
+    {
20 23
         return password_verify($password, $this->password);
21 24
     }
22 25
 }
@@ -24,20 +27,25 @@ class User extends Model {
24 27
 /**
25 28
  * Class Question
26 29
  * @property int $id
30
+ * @property int $user_id
27 31
  * @property string $title
28 32
  * @property string $description
29 33
  * @property string $type
30 34
  */
31
-class Question extends Model {
32
-    public function configs() {
35
+class Question extends Model
36
+{
37
+    public function configs()
38
+    {
33 39
         return $this->has_many('QuestionConfig');
34 40
     }
35 41
 
36
-    public function config($key) {
42
+    public function config($key)
43
+    {
37 44
         return QuestionConfig::where('key', $key)->find_one();
38 45
     }
39 46
 
40
-    public function responses() {
47
+    public function responses()
48
+    {
41 49
         switch ($this->type) {
42 50
             case 'freeform':
43 51
                 return FreeFormResponse::where('question_id', $this->id);
@@ -50,7 +58,8 @@ class Question extends Model {
50 58
         return null;
51 59
     }
52 60
 
53
-    public function response($response_id) {
61
+    public function response($response_id)
62
+    {
54 63
         switch ($this->type) {
55 64
             case 'freeform':
56 65
                 return FreeFormResponse::where('id', $response_id);
@@ -63,13 +72,15 @@ class Question extends Model {
63 72
         return null;
64 73
     }
65 74
 
66
-    public function choices() {
75
+    public function choices()
76
+    {
67 77
         if ($this->type == 'freeform' || $this->type == 'numeric') return null;
68 78
 
69 79
         return $this->has_many('ResponseChoice');
70 80
     }
71 81
 
72
-    public function choice($choice_id) {
82
+    public function choice($choice_id)
83
+    {
73 84
         if ($this->type == 'freeform' || $this->type == 'numeric') return null;
74 85
 
75 86
         return $this->has_one('ResponseChoice');
@@ -83,8 +94,10 @@ class Question extends Model {
83 94
  * @property string $key
84 95
  * @property string $value
85 96
  */
86
-class QuestionConfig extends Model {
87
-    public function question() {
97
+class QuestionConfig extends Model
98
+{
99
+    public function question()
100
+    {
88 101
         return $this->belongs_to('Question');
89 102
     }
90 103
 }
@@ -95,8 +108,10 @@ class QuestionConfig extends Model {
95 108
  * @property int $question_id
96 109
  * @property string $value
97 110
  */
98
-class ResponseChoice extends Model {
99
-    public function question() {
111
+class ResponseChoice extends Model
112
+{
113
+    public function question()
114
+    {
100 115
         return $this->belongs_to('Question');
101 116
     }
102 117
 }
@@ -105,8 +120,10 @@ class ResponseChoice extends Model {
105 120
  * Class Response
106 121
  * A generic response type extended by more specific types.
107 122
  */
108
-class Response extends Model {
109
-    public function question() {
123
+class Response extends Model
124
+{
125
+    public function question()
126
+    {
110 127
         return $this->belongs_to('Question');
111 128
     }
112 129
 }
@@ -117,16 +134,8 @@ class Response extends Model {
117 134
  * @property int $question_id
118 135
  * @property string $value
119 136
  */
120
-class FreeFormResponse extends Response {
121
-}
122
-
123
-/**
124
- * Class NumericResponse
125
- * @property int $id
126
- * @property int $question_id
127
- * @property int $value
128
- */
129
-class NumericResponse extends Response {
137
+class FreeFormResponse extends Response
138
+{
130 139
 }
131 140
 
132 141
 /**
@@ -135,8 +144,10 @@ class NumericResponse extends Response {
135 144
  * @property int $question_id
136 145
  * @property int $choice_id
137 146
  */
138
-class SingleChoiceResponse extends Response {
139
-    public function choice() {
147
+class SingleChoiceResponse extends Response
148
+{
149
+    public function choice()
150
+    {
140 151
         return ResponseChoice::where('id', $this->choice_id);
141 152
     }
142 153
 }
@@ -203,6 +214,8 @@ $klein->with('/admin', function () use ($klein) {
203 214
     });
204 215
 
205 216
     $klein->respond('GET', '/login', function ($request, $response, $service, $app) {
217
+        if (!User::find_one()) return $response->redirect('/admin/setup');
218
+
206 219
         return $app->twig->render('admin/login.html');
207 220
     });
208 221
 
@@ -363,6 +376,7 @@ $klein->with('/admin', function () use ($klein) {
363 376
 
364 377
             $question = Question::create();
365 378
 
379
+            $question->user_id = $app->user->id;
366 380
             $question->title = $request->title;
367 381
             $question->description = $request->description;
368 382
             $question->type = $request->type;
@@ -377,8 +391,14 @@ $klein->with('/admin', function () use ($klein) {
377 391
 $klein->with('/api/v1', function () use ($klein) {
378 392
     $klein->with('/question/[i:id]', function () use ($klein) {
379 393
         $klein->respond('GET', '', function ($request, $response, $service, $app) {
394
+            $response->header('Access-Control-Allow-Origin', '*');
395
+
380 396
             $question = Question::find_one($request->id);
381 397
 
398
+            if (!$question) {
399
+                return $response->json(['error' => 'unknown question']);
400
+            }
401
+
382 402
             $data = $question->as_array();
383 403
 
384 404
             if ($question->type == 'single') {
@@ -390,12 +410,23 @@ $klein->with('/api/v1', function () use ($klein) {
390 410
 
391 411
         $klein->with('/response', function () use ($klein) {
392 412
             $klein->respond('GET', '', function ($request, $response, $service, $app) {
413
+                $response->header('Access-Control-Allow-Origin', '*');
414
+
393 415
                 $question = Question::find_one($request->id);
394 416
 
417
+                if (!$question) {
418
+                    return $response->json([
419
+                        'ok' => false,
420
+                        'error' => 'unknown question'
421
+                    ]);
422
+                }
423
+
395 424
                 switch ($question->type) {
396 425
                     case 'freeform':
397 426
                     case 'numeric':
398
-                        return $response->json($question->responses()->find_array());
427
+                        return $response->json([
428
+                            'responses' => $question->responses()->find_array(),
429
+                        ]);
399 430
                     case 'single':
400 431
                         $items = $question->responses()->find_many();
401 432
                         $responses = [];
@@ -404,34 +435,92 @@ $klein->with('/api/v1', function () use ($klein) {
404 435
                             $data['choice'] = ResponseChoice::find_one($item->choice_id)->value;
405 436
                             $responses[] = $data;
406 437
                         }
407
-                        return $response->json($responses);
438
+                        return $response->json([
439
+                            'responses' => $responses,
440
+                        ]);
408 441
                     default:
409 442
                         return 'invalid type';
410 443
                 }
411 444
             });
412 445
 
446
+            $klein->respond('OPTIONS', '/add', function ($request, $response) {
447
+                $response->header('Access-Control-Allow-Origin', '*');
448
+                $response->header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
449
+                $response->header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
450
+
451
+                return '';
452
+            });
453
+
413 454
             $klein->respond('POST', '/add', function ($request, $response, $service, $app) {
455
+                $response->header('Access-Control-Allow-Origin', '*');
456
+                $response->header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
457
+
414 458
                 $json = json_decode($request->body());
415 459
                 $question = Question::find_one($request->id);
416 460
 
461
+                if (!$question) {
462
+                    return $response->json([
463
+                        'ok' => false,
464
+                        'error' => 'unknown question'
465
+                    ]);
466
+                }
467
+
417 468
                 switch ($question->type) {
418 469
                     case 'freeform':
470
+                        if ($json->value == '') {
471
+                            return $response->json([
472
+                                'ok' => false,
473
+                                'error' => 'missing input',
474
+                            ]);
475
+                        }
476
+
419 477
                         $freeform = FreeFormResponse::create();
420 478
                         $freeform->question_id = $question->id;
421 479
                         $freeform->value = $json->value;
422 480
                         $freeform->save();
423 481
                         break;
424 482
                     case 'numeric':
425
-                        $numeric = NumericResponse::create();
483
+                        if ($json->value == '' || !is_numeric($json->value)) {
484
+                            return $response->json([
485
+                                'ok' => false,
486
+                                'error' => 'missing or invalid input',
487
+                            ]);
488
+                        }
489
+
490
+                        $numeric = FreeFormResponse::create();
426 491
                         $numeric->question_id = $question->id;
427 492
                         $numeric->value = $json->value;
428 493
                         $numeric->save();
429 494
                         break;
495
+                    case 'single':
496
+                        if ($json->value == '') {
497
+                            return $response->json([
498
+                                'ok' => false,
499
+                                'error' => 'missing input',
500
+                            ]);
501
+                        }
502
+                        $response_choice = ResponseChoice::where('question_id', $question->id)->where('id', $json->value)->find_one();
503
+                        if (!$response_choice) {
504
+                            return $response->json([
505
+                                'ok' => false,
506
+                                'error' => 'invalid choice',
507
+                            ]);
508
+                        }
509
+                        $choice = SingleChoiceResponse::create();
510
+                        $choice->question_id = $question->id;
511
+                        $choice->choice_id = $response_choice->id;
512
+                        $choice->save();
513
+                        break;
430 514
                     default:
431
-                        return 'invalid type';
515
+                        return $response->json([
516
+                            'ok' => false,
517
+                            'error' => 'server misconfiguration',
518
+                        ]);
432 519
                 }
433 520
 
434
-                return 'Added.';
521
+                return $response->json([
522
+                    'ok' => true,
523
+                ]);
435 524
             });
436 525
         });
437 526
     });
@@ -441,4 +530,8 @@ $klein->respond('GET', '/peppershrike.js', function ($request, $response) {
441 530
     return $response->file(__DIR__ . '/peppershrike.js');
442 531
 });
443 532
 
533
+$klein->respond('GET', '/themes/[:theme]', function ($request, $response) {
534
+    return $response->file(__DIR__ . '/themes/' . $request->theme, null, 'text/css');
535
+});
536
+
444 537
 $klein->dispatch();

+ 47
- 0
install.sql View File

@@ -0,0 +1,47 @@
1
+CREATE TABLE user (
2
+  id INTEGER PRIMARY KEY,
3
+  username TEXT UNIQUE NOT NULL,
4
+  password TEXT NOT NULL
5
+);
6
+
7
+CREATE TABLE question (
8
+  id INTEGER PRIMARY KEY,
9
+  user_id INTEGER NOT NULL,
10
+  title TEXT NOT NULL,
11
+  description TEXT,
12
+  type TEXT NOT NULL,
13
+  FOREIGN KEY (user_id) REFERENCES user(id),
14
+  CHECK (type IN ('freeform', 'numeric', 'single', 'multiple'))
15
+);
16
+
17
+CREATE TABLE question_config (
18
+  id INTEGER PRIMARY KEY,
19
+  question_id INTEGER NOT NULL,
20
+  key TEXT NOT NULL,
21
+  value TEXT NOT NULL,
22
+  FOREIGN KEY (question_id) REFERENCES question(id),
23
+  UNIQUE (question_id, key)
24
+);
25
+
26
+CREATE TABLE response_choice (
27
+  id INTEGER PRIMARY KEY,
28
+  question_id INTEGER NOT NULL,
29
+  value TEXT NOT NULL,
30
+  FOREIGN KEY (question_id) REFERENCES question(id),
31
+  UNIQUE (question_id, value)
32
+);
33
+
34
+CREATE TABLE free_form_response (
35
+  id INTEGER PRIMARY KEY,
36
+  question_id INTEGER NOT NULL,
37
+  value TEXT NOT NULL,
38
+  FOREIGN KEY (question_id) REFERENCES question(id)
39
+);
40
+
41
+CREATE TABLE single_choice_response (
42
+  id INTEGER PRIMARY KEY,
43
+  question_id INTEGER NOT NULL,
44
+  choice_id INTEGER NOT NULL,
45
+  FOREIGN KEY (question_id) REFERENCES question(id),
46
+  FOREIGN KEY (choice_id) REFERENCES choice(id)
47
+);

+ 121
- 36
peppershrike.js View File

@@ -1,27 +1,45 @@
1
-const HOST = 'http://localhost:80/api/v1';
2
-let DONE_MESSAGE = 'Thank you!';
3
-
4 1
 class Peppershrike {
5
-    constructor(id, container) {
2
+    constructor(
3
+        id,
4
+        container,
5
+        host,
6
+        doneMessage = 'Thank you!',
7
+        errorMessage = 'An error occurred',
8
+    ) {
6 9
         this.id = id;
7 10
         this.container = container;
8 11
 
12
+        this.host = host;
13
+
14
+        this.doneMessage = doneMessage;
15
+        this.errorMessage = errorMessage;
16
+
9 17
         this.init();
10 18
     }
11 19
 
12 20
     async init() {
13
-        this.question = await Peppershrike.getQuestion(this.id);
21
+        this.question = await this.getQuestion(this.id);
22
+
23
+        if (this.question['error'] !== undefined) {
24
+            return this.displayMessage(this.question['error'], 'peppershrike-error');
25
+        }
26
+
14 27
         this.buildMainView();
28
+
15 29
         switch (this.question.type) {
16
-        case 'freeform':
17
-        case 'numeric':
18
-            this.buildFreeFormQuestion(this.question);
19
-            break;
30
+            case 'freeform':
31
+            case 'numeric':
32
+                this.buildFreeFormQuestion(this.question);
33
+                break;
34
+            case 'single':
35
+                this.singleChoiceFormQuestion(this.question);
36
+                break;
20 37
         }
38
+
21 39
         this.container.appendChild(this.form);
22 40
     }
23 41
 
24
-    async handleFormSubmit (ev) {
42
+    async handleFormSubmit(ev) {
25 43
         ev.preventDefault();
26 44
 
27 45
         this.container.classList.add('peppershrike-loading');
@@ -35,47 +53,77 @@ class Peppershrike {
35 53
             data[input.dataset.name] = input.value;
36 54
         });
37 55
 
38
-        await fetch(ev.target.action, {
39
-            body: JSON.stringify(data),
40
-            headers: {
41
-                'Content-Type': 'application/json',
42
-            },
43
-            method: 'POST',
44
-        });
56
+        try {
57
+            const resp = await fetch(ev.target.action, {
58
+                body: JSON.stringify(data),
59
+                headers: {
60
+                    'Content-Type': 'application/json',
61
+                },
62
+                method: 'POST',
63
+            });
64
+
65
+            const json = await resp.json();
66
+
67
+            if (!json['ok']) {
68
+                this.container.classList.remove('peppershrike-loading');
69
+                this.container.classList.add('peppershrike-error');
70
+
71
+                this.displayMessage(json['error'], 'peppershrike-error');
72
+                return;
73
+            }
74
+        } catch (err) {
75
+            this.container.classList.remove('peppershrike-loading');
76
+            this.container.classList.add('peppershrike-error');
77
+
78
+            this.displayMessage(this.errorMessage, 'peppershrike-error');
79
+
80
+            return;
81
+        }
45 82
 
46 83
         this.container.classList.remove('peppershrike-loading');
47 84
 
48
-        this.form.style.display = 'none';
85
+        this.displayMessage(this.doneMessage, 'peppershrike-done');
86
+    }
49 87
 
50
-        this.form.parentNode.innerHTML = DONE_MESSAGE;
88
+    displayMessage(message, cls = null) {
89
+        const div = document.createElement('div');
90
+        if (cls) {
91
+            div.classList.add(cls);
92
+        }
93
+        div.innerHTML = message;
94
+        this.container.innerHTML = '';
95
+        this.container.appendChild(div);
51 96
     }
52 97
 
53
-    buildMainView () {
98
+    buildMainView() {
54 99
         const div = document.createElement('div');
55 100
         div.classList.add('peppershrike-info');
56 101
 
57 102
         const title = document.createElement('div');
58 103
         title.classList.add('peppershrike-title');
59
-        title.innerHTML = this.question.title;
60
-
61
-        const description = document.createElement('div');
62
-        description.classList.add('peppershrike-description');
63
-        description.innerHTML = this.question.description;
104
+        title.innerText = this.question.title;
64 105
 
65 106
         div.appendChild(title);
66
-        div.appendChild(description);
107
+
108
+        if (this.question.description !== '') {
109
+            const description = document.createElement('div');
110
+            description.classList.add('peppershrike-description');
111
+            description.innerText = this.question.description;
112
+
113
+            div.appendChild(description);
114
+        }
67 115
 
68 116
         this.container.appendChild(div);
69 117
     }
70 118
 
71
-    buildFreeFormQuestion () {
119
+    buildFreeFormQuestion() {
72 120
         const form = document.createElement('form');
73 121
         this.form = form;
74 122
 
75 123
         form.dataset.type = this.question.type;
76 124
 
77 125
         form.method = 'POST';
78
-        form.action = `${HOST}/question/${this.question.id}/response/add`;
126
+        form.action = `${this.host}/question/${this.question.id}/response/add`;
79 127
 
80 128
         const input = document.createElement('input');
81 129
         input.dataset.name = 'value';
@@ -83,6 +131,7 @@ class Peppershrike {
83 131
         input.classList.add('peppershrike-input', 'peppershrike-freeform-response');
84 132
 
85 133
         if (this.question.type === 'numeric') {
134
+            input.placeholder += ' (as a number)';
86 135
             input.type = 'number';
87 136
         }
88 137
 
@@ -98,30 +147,66 @@ class Peppershrike {
98 147
 
99 148
         form.appendChild(submit);
100 149
 
101
-        input.addEventListener('keyup', ev => {
150
+        input.addEventListener('input', ev => {
102 151
             submit.disabled = input.value === '';
103 152
         });
104 153
 
105 154
         form.addEventListener('submit', this.handleFormSubmit.bind(this));
106 155
     }
107 156
 
108
-    static async getQuestion (id) {
109
-        const resp = await fetch(`${HOST}/question/${id}`);
157
+    singleChoiceFormQuestion() {
158
+        const form = document.createElement('form');
159
+        this.form = form;
160
+
161
+        form.dataset.type = this.question.type;
162
+
163
+        form.method = 'POST';
164
+        form.action = `${this.host}/question/${this.question.id}/response/add`;
165
+
166
+        const select = document.createElement('select');
167
+        select.classList.add('peppershrike-input', 'peppershrike-single-response');
168
+        select.dataset.name = 'value';
169
+
170
+        this.inputs = [select];
171
+
172
+        const choices = this.question.choices;
173
+
174
+        choices.forEach(choice => {
175
+            const item = document.createElement('option');
176
+            item.value = choice.id;
177
+            item.innerText = choice.value;
178
+            select.appendChild(item);
179
+        });
180
+
181
+        form.appendChild(select);
182
+
183
+        const submit = document.createElement('button');
184
+        this.submit = submit;
185
+        submit.innerHTML = 'Submit';
186
+        submit.classList.add('peppershrike-submit');
187
+
188
+        form.appendChild(submit);
189
+
190
+        form.addEventListener('submit', this.handleFormSubmit.bind(this));
191
+    }
192
+
193
+    async getQuestion(id) {
194
+        const resp = await fetch(`${this.host}/question/${id}`);
110 195
         return await resp.json();
111 196
     }
112 197
 
113
-    static async getResponses (id) {
114
-        const resp = await fetch(`${HOST}/question/${id}/response`);
198
+    async getResponses(id) {
199
+        const resp = await fetch(`${this.host}/question/${id}/response`);
115 200
         return await resp.json();
116 201
     }
117 202
 
118
-    static find() {
203
+    static find(host) {
119 204
         const questions = Array.from(document.querySelectorAll('.peppershrike[data-id]'));
120 205
 
121 206
         questions.forEach(async question => {
122
-            new Peppershrike(question.dataset.id, question);
207
+            new Peppershrike(question.dataset.id, question, host);
123 208
         })
124 209
     }
125 210
 }
126 211
 
127
-Peppershrike.find();
212
+Peppershrike.find('http://localhost:80/api/v1');

+ 5
- 2
templates/index.html View File

@@ -5,15 +5,18 @@
5 5
     <meta name="viewport" content="width=device-width">
6 6
 
7 7
     <title>peppershrike</title>
8
+
9
+    <link rel="stylesheet" href="/themes/syfaro.css">
8 10
 </head>
9 11
 <body>
10 12
 <h1>peppershrike</h1>
11 13
 
12 14
 <h2>example:</h2>
13
-<div class="peppershrike" data-id="1"></div>
14 15
 
16
+<div class="peppershrike" data-id="1"></div>
15 17
 <div class="peppershrike" data-id="2"></div>
18
+<div class="peppershrike" data-id="3"></div>
16 19
 
17 20
 <script src="peppershrike.js"></script>
18 21
 </body>
19
-</html>
22
+</html>

+ 55
- 0
themes/syfaro.css View File

@@ -0,0 +1,55 @@
1
+.peppershrike {
2
+    font-family: -apple-system, "Helvetica Neue", "Lucida Sans Unicode", "Lucida Grande", sans-serif;
3
+    width: 80%;
4
+    max-width: 350px;
5
+    margin: 5px auto;
6
+    padding: 20px 5px;
7
+    color: black;
8
+    background-color: #eadef6;
9
+    font-size: 1.2em;
10
+    border-radius: 5px;
11
+    font-variant-ligatures: common-ligatures discretionary-ligatures;
12
+}
13
+
14
+.peppershrike-title {
15
+    font-size: 1.5em;
16
+}
17
+
18
+.peppershrike-input,
19
+.peppershrike-submit {
20
+    margin: 5px 0;
21
+    font-family: -apple-system, "Helvetica Neue", "Lucida Sans Unicode", "Lucida Grande", sans-serif;
22
+    font-size: 1em;
23
+    padding: 2px 5px;
24
+}
25
+
26
+.peppershrike-input {
27
+    box-sizing: border-box;
28
+    display: block;
29
+    width: 100%;
30
+    border: none;
31
+    font-variant-ligatures: none;
32
+}
33
+
34
+.peppershrike-input[type="number"] {
35
+    font-variant-numeric: tabular-nums lining-nums;
36
+}
37
+
38
+.peppershrike-submit {
39
+    display: block;
40
+    width: 100%;
41
+    background-color: #362b70;
42
+    border: none;
43
+    color: white;
44
+    transition: all 0.2s;
45
+}
46
+
47
+.peppershrike-submit:disabled {
48
+    background-color: #a77bdb;
49
+    color: lightgray;
50
+}
51
+
52
+.peppershrike-done,
53
+.peppershrike-error {
54
+    text-align: center;
55
+}

Loading…
Cancel
Save