Skip to content

Commit

Permalink
create a poll
Browse files Browse the repository at this point in the history
  • Loading branch information
jelhan committed Apr 15, 2023
1 parent fa747c6 commit 16e5dc1
Show file tree
Hide file tree
Showing 23 changed files with 1,472 additions and 20 deletions.
48 changes: 37 additions & 11 deletions app/components/create-poll.hbs
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
<form {{on "submit" this.createPoll}}>
<h1>Create a poll</h1>

<form {{on "submit" this.createPoll.perform}}>
{{#let (unique-id) as |inputId|}}
<div>
<label for={{inputId}}>
<div class="mb-3">
<label class="form-label" for={{inputId}}>
Title
</label>
<input id={{inputId}} name="title" type="text" required />
<input
class="form-control"
id={{inputId}}
name="title"
type="text"
required
/>
</div>
{{/let}}

{{#let (unique-id) as |textareaId|}}
<div>
<label for={{textareaId}}>
<div class="mb-3">
<label class="form-label" for={{textareaId}}>
Description
</label>
<textarea id={{textareaId}} name="description"></textarea>
<textarea
class="form-control"
id={{textareaId}}
name="description"
></textarea>
</div>
{{/let}}

{{#let (unique-id) as |inputId|}}
<div>
<label for={{inputId}}>
<div class="mb-3">
<label class="form-label" for={{inputId}}>
Add a date
</label>
<input
class="form-control"
id={{inputId}}
name="add-date"
required={{if this.dates.size false true}}
Expand All @@ -45,7 +58,20 @@
{{/each}}
</ol>

<button type="submit">
Create
<button
class="btn btn-primary"
disabled={{this.createPoll.isRunning}}
type="submit"
>
{{#if this.createPoll.isRunning}}
Creating
<span
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{else}}
Create
{{/if}}
</button>
</form>
43 changes: 39 additions & 4 deletions app/components/create-poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { action } from '@ember/object';
import { service } from '@ember/service';
import RouterService from '@ember/routing/router-service';
import { TrackedSet } from 'tracked-built-ins';
import { Option, Poll } from 'croodle/models';
import { task } from 'ember-concurrency';

export interface CreatePollSignature {
Element: null;
Expand Down Expand Up @@ -41,12 +43,45 @@ export default class CreatePollComponent extends Component<CreatePollSignature>
this.dates.delete(date);
}

@action
createPoll(event: SubmitEvent) {
createPoll = task({ drop: true }, async (event: SubmitEvent) => {
event.preventDefault();

const pollId = window.crypto.randomUUID();
const form = event.target;

if (!(form instanceof HTMLFormElement)) {
throw new Error('Event target is not a form');
}
const formData = new FormData(form);
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const options = Array.from(this.dates).map((date) => new Option({ date }));

const poll = new Poll({
description,
options,
title,
});

try {
await poll.save();
} catch (error) {
reportError(error);

// TODO: Distinguish different reasons why saving the poll failed and report
// most helpful error message to the user.
window.alert(
'Saving the poll failed. Please check your network connection and try again.'
);
}

this.router.transitionTo('poll', poll.id, {
queryParams: { k: poll.passphrase },
});
});
}

this.router.transitionTo('poll', pollId);
declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
CreatePoll: typeof CreatePollComponent;
}
}
5 changes: 5 additions & 0 deletions app/controllers/poll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Controller from '@ember/controller';

export default class PollController extends Controller {
queryParams = ['k'];
}
4 changes: 4 additions & 0 deletions app/mocks/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { setupWorker } from 'msw';
import handlers from './handlers';

export const worker = setupWorker(...handlers);
48 changes: 48 additions & 0 deletions app/mocks/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
type EncryptedValue = {
ciphertext: Array<number>;
iv: Array<number>;
};

type PollAttributes = {
encryptedDescription: EncryptedValue;
encryptedTitle: EncryptedValue;
salt: Array<number>;
};

export class Poll {
id: string;
attributes: PollAttributes;
options: Option[] = [];

constructor({ id, attributes }: { id: string; attributes: PollAttributes }) {
this.id = id;
this.attributes = attributes;
}

addOption(data: { id: string; attributes: OptionAttributes }) {
const option = new Option(data);
this.options.push(option);
}
}

type OptionAttributes = {
encryptedDate: EncryptedValue;
};

export class Option {
id: string;
attributes: OptionAttributes;

constructor({
id,
attributes,
}: {
id: string;
attributes: OptionAttributes;
}) {
this.id = id;
this.attributes = attributes;
}
}

export const polls = new Map<string, Poll>();
58 changes: 58 additions & 0 deletions app/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { rest } from 'msw';
import { Poll, polls } from './db';

export const createPollHandler = rest.post('/polls', async (req, res, ctx) => {
const document = await req.json();
const poll = new Poll(document['bulk:data'][0]);

for (const optionResourceObject of document['bulk:included']) {
poll.addOption(optionResourceObject);
}

polls.set(poll.id, poll);

return res(ctx.status(204));
});

export const getPollHandler = rest.get('/polls/:pollId', (req, res, ctx) => {
const { pollId } = req.params;

if (typeof pollId !== 'string') {
throw new Error(`${pollId} is not a valid ID for a poll`);
}

const poll = polls.get(pollId);
if (!poll) {
return res(ctx.status(404));
}

const document = {
data: {
id: poll.id,
type: 'polls',
attributes: poll.attributes,
relationships: {
options: {
data: poll.options.map((option) => {
return {
id: option.id,
type: 'options',
};
}),
},
},
},
included: [
poll.options.map((option) => {
return {
id: option.id,
attributes: option.attributes,
};
}),
],
};

return res(ctx.status(200), ctx.json(document), ctx.delay());
});

export default [createPollHandler, getPollHandler];
4 changes: 4 additions & 0 deletions app/models/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Option from './option';
import Poll from './poll';

export { Option, Poll };
12 changes: 12 additions & 0 deletions app/models/option.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
type OptionSignature = {
date: string;
};

export default class Option {
readonly id: string = window.crypto.randomUUID();
readonly date: string;

constructor({ date }: OptionSignature) {
this.date = date;
}
}
79 changes: 79 additions & 0 deletions app/models/poll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import Option from './option';
import { action } from '@ember/object';
import {
deriveKey,
encrypt,
randomPassphrase,
randomSalt,
} from 'croodle/utils/crypto';

type PollSignature = {
description: string;
options: Option[];
title: string;
};

export default class Poll {
readonly description: string;
readonly id: string = window.crypto.randomUUID();
readonly options: Option[];
readonly passphrase: string = randomPassphrase();
readonly salt: Uint8Array = randomSalt();
readonly title: string;

constructor({ description, options, title }: PollSignature) {
this.description = description;
this.options = options;
this.title = title;
}

@action
async save(): Promise<void> {
const { passphrase, salt } = this;
const key = await deriveKey(passphrase, salt);

const document = {
'bulk:data': [
{
type: 'polls',
id: this.id,
attributes: {
encryptedDescription: await encrypt(this.description, key),
encryptedTitle: await encrypt(this.title, key),
salt: Array.from(this.salt),
},
},
],
'bulk:included': await Promise.all(
this.options.map(async (option) => {
return {
type: 'options',
id: option.id,
attributes: {
encryptedDate: await encrypt(option.date, key),
},
relationships: {
poll: {
data: {
type: 'poll',
id: this.id,
},
},
},
};
})
),
};

await fetch('/polls', {
body: JSON.stringify(document),
headers: {
'Content-Type':
'application/vnd.api+json; ext="https://github.com/jelhan/json-api-bulk-create-extension"',
},
method: 'POST',
});

return;
}
}
12 changes: 12 additions & 0 deletions app/routes/application.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Route from '@ember/routing/route';

const SHOULD_MOCK_API = true;

export default class ApplicationRoute extends Route {
async beforeModel() {
if (SHOULD_MOCK_API) {
const { worker } = await import('../mocks/browser');
worker.start();
}
}
}
4 changes: 3 additions & 1 deletion app/templates/application.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{{page-title "Croodle"}}

{{outlet}}
<div class="container">
{{outlet}}
</div>
Loading

0 comments on commit 16e5dc1

Please sign in to comment.