diff --git a/api/hff_entry_forms_api.config.json b/api/hff_entry_forms_api.config.json index 053ee7d..96a4bf2 100644 --- a/api/hff_entry_forms_api.config.json +++ b/api/hff_entry_forms_api.config.json @@ -1,6 +1,6 @@ { "eventParentId": "ffff5ca5cb0547b6a99fb78cdb9b0170", - "knownOrigins": [ "http://curl.localhost" ], + "knownOrigins": [ "http://localhost:8080", "http://curl.localhost" ], "notionApiBaseUrl": "https://api.notion.com/v1", "notionVersion": "2021-08-16", "port": 8300 diff --git a/api/src/hff_entry_forms_apipkg/api.nim b/api/src/hff_entry_forms_apipkg/api.nim index b54d6ca..17817ac 100644 --- a/api/src/hff_entry_forms_apipkg/api.nim +++ b/api/src/hff_entry_forms_apipkg/api.nim @@ -126,9 +126,15 @@ proc start*(cfg: HffEntryFormsApiConfig): void = get "/version": jsonResp(Http200, $(%("hff_entry_forms_api v" & HFF_ENTRY_FORMS_API_VERSION))) - options "/add-page": optionsResp(@[HttpPost]) + options "/event-proposals/config": optionsResp(@[HttpGet]) - post "/propose-event": + get "/event-proposals/config": + withApiErrors: + dataResp(%getEventProposalConfig(cfg)) + + options "/event-proposals": optionsResp(@[HttpPost]) + + post "/event-proposals": withApiErrors: let ep = parseEventProposal(parseJson(request.body)) if createProposedEvent(cfg, ep): statusResp(Http200) diff --git a/api/src/hff_entry_forms_apipkg/models.nim b/api/src/hff_entry_forms_apipkg/models.nim index 25992da..8feec9d 100644 --- a/api/src/hff_entry_forms_apipkg/models.nim +++ b/api/src/hff_entry_forms_apipkg/models.nim @@ -10,6 +10,11 @@ type date*: DateTime budgetInDollars*: int + MultiSelectOption = tuple[value: string, color: string] + + EventProposalConfig* = object + departmentOptions*: seq[MultiSelectOption] + proc getOrFail(n: JsonNode, key: string): JsonNode = ## convenience method to get a key from a JObject or raise an exception if not n.hasKey(key): @@ -75,3 +80,14 @@ proc asNotionPage*(ep: EventProposal): JsonNode = } ] } + +proc `%`(mso: MultiSelectOption): JsonNode = + %*{ + "value": mso.value, + "color": mso.color + } + +proc `%`*(epc: EventProposalConfig): JsonNode = + %*{ + "departments": epc.departmentOptions + } diff --git a/api/src/hff_entry_forms_apipkg/notion_client.nim b/api/src/hff_entry_forms_apipkg/notion_client.nim index 0abb52c..434de2b 100644 --- a/api/src/hff_entry_forms_apipkg/notion_client.nim +++ b/api/src/hff_entry_forms_apipkg/notion_client.nim @@ -1,16 +1,43 @@ -import json, logging, std/httpclient, strutils +import json, logging, std/httpclient, sequtils, strutils import ./models, ./service -proc createProposedEvent*(cfg: HffEntryFormsApiConfig, ep: EventProposal): bool = +proc makeHttpClient(cfg: HffEntryFormsApiConfig): HttpClient = let headers = newHttpHeaders([ ("Content-Type", "application/json"), ("Authorization", "Bearer " & cfg.integrationToken), ("Notion-Version", cfg.notionVersion) ], true) - let http = newHttpClient(headers = headers, ) - debug $headers + return newHttpClient(headers = headers, ) + +proc getEventProposalConfig*(cfg: HffEntryFormsApiConfig): EventProposalConfig = + let http = makeHttpClient(cfg) + + let apiResp = http.get(cfg.notionApiBaseUrl & "/databases/" & cfg.eventParentId) + debug apiResp.status + + if not apiResp.status.startsWith("2"): + debug apiResp.body + raiseApiError(Http500, + "unable to read event propsal configuration from notion API") + + let bodyJson = parseJson(apiResp.body) + let departmentOptionsJson = bodyJson{ + "properties", "Department", "multi_select", "options"} + + if departmentOptionsJson.isNil: + raiseApiError(Http500, + "missing read department values from Notion API-supplied event schema") + + return EventProposalConfig( + departmentOptions: departmentOptionsJson.toSeq.mapIt( + ( value: it["name"].getStr, color: it["color"].getStr ) + ) + ) + +proc createProposedEvent*(cfg: HffEntryFormsApiConfig, ep: EventProposal): bool = + let http = makeHttpClient(cfg) let epNotionPage = ep.asNotionPage epNotionPage["parent"] = %*{ "database_id": cfg.eventParentId } diff --git a/web/.env b/web/.env new file mode 100644 index 0000000..ccaeabd --- /dev/null +++ b/web/.env @@ -0,0 +1 @@ +VUE_APP_API_BASE_URL=https://forms-api-dev.hopefamilyfellowship.com/v1/ diff --git a/web/.env.production b/web/.env.production new file mode 100644 index 0000000..62cb001 --- /dev/null +++ b/web/.env.production @@ -0,0 +1 @@ +VUE_APP_API_BASE_URL=https://forms-api.hopefamilyfellowship.com/v1/ diff --git a/web/package-lock.json b/web/package-lock.json index c0c3842..ef64062 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -85,6 +85,40 @@ "postcss": "^7.0.0" } }, + "@jdbernard/logging": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@jdbernard/logging/-/logging-1.1.3.tgz", + "integrity": "sha512-JS+kk/O+rg5TVHf+Fg9o4s0Al4nwkwd0vsrPxawUJbgjeC6GPwYi/fQHMRYl5XY2zvy+o3EoEC9o+4LIfJx/6A==", + "requires": { + "axios": "^0.19.2" + }, + "dependencies": { + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + } + } + } + }, "@mrmlnc/readdir-enhanced": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", @@ -1697,6 +1731,14 @@ "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", "dev": true }, + "axios": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", + "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", + "requires": { + "follow-redirects": "^1.14.4" + } + }, "babel-code-frame": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", @@ -3367,6 +3409,11 @@ "assert-plus": "^1.0.0" } }, + "dayjs": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz", + "integrity": "sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==" + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -4772,8 +4819,7 @@ "follow-redirects": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", - "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==", - "dev": true + "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==" }, "for-in": { "version": "1.0.2", @@ -6916,8 +6962,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "multicast-dns": { "version": "6.2.3", diff --git a/web/package.json b/web/package.json index 767efa6..f581ac5 100644 --- a/web/package.json +++ b/web/package.json @@ -5,10 +5,14 @@ "scripts": { "serve": "npx servor dist", "build": "vue-cli-service build", + "build-local": "vue-cli-service build --mode development", "lint": "vue-cli-service lint", "vue-serve": "vue-cli-service serve" }, "dependencies": { + "@jdbernard/logging": "^1.1.3", + "axios": "^0.23.0", + "dayjs": "^1.10.7", "vue": "^3.0.0", "vue-router": "^4.0.0-0" }, diff --git a/web/src/App.scss b/web/src/App.scss new file mode 100644 index 0000000..0fc4a10 --- /dev/null +++ b/web/src/App.scss @@ -0,0 +1,73 @@ +@import "~@/styles/forSize.mixin"; + +html { font-size: 16px; } + +#app { + font-family: "Segoe UI", Helvetica, Arial, sans-serif; + box-sizing: border-box; + + button, input, select, textarea { + font-size: inherit; + + &:disabled { cursor: not-allowed; } + } + + button { + color: #1084AC; + background-color: #1084AC0A; + border: solid thin #1084AC; + border-radius: 0.25em; + font-weight: bold; + font-size: 125%; + padding: 0.5em; + + &:not(:disabled) { + &:hover, &:focus { + color: white; + background-color: #1084AC; + box-shadow: 0.125em 0.125em 0.25em #aaa; + } + + &:active { + box-shadow: 0 0 0.125em #999; + position: relative; + top: 0.125em; + left: 0.125em; + } + } + + &:disabled { opacity: 0.75; } + } +} + +.spin { + animation-name: spin; + animation-duration: 1000ms; + animation-iteration-count: infinite; + animation-timing-function: linear; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.tumble { + animation-name: spin; + animation-duration: 2000ms; + animation-iteration-count: infinite; + animation-timing-function: ease-in-out; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(180deg); } +} + +@include forSize(mobile) { + body { margin: 0; } +} + +@include forSize(ultrawide) { + html { font-size: 24px; } +} diff --git a/web/src/App.vue b/web/src/App.vue index 113b6b8..d4a31f3 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,30 +1,4 @@ - - + diff --git a/web/src/api-client/event-proposal.models.ts b/web/src/api-client/event-proposal.models.ts new file mode 100644 index 0000000..b70dcc2 --- /dev/null +++ b/web/src/api-client/event-proposal.models.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { default as dayjs } from 'dayjs'; + +interface MutableEventProposalModel { + name: string; + description: string; + purpose: string; + department: string; + location: string; + owner: string; + date: Date; + budgetInDollars: number; +} + +interface MutableEventProposalConfig { + departments: Array<{ + value: string; + color: string; + }>; +} + +export type EventProposalModel = Readonly; +export type EventProposalConfig = Readonly; + +// eslint-disable-next-line +export function eventProposalConfigFromDTO(dto: any): EventProposalConfig { + const { departments } = dto; + return { departments }; +} + +export function eventProposalModelToDTO(ep: EventProposalModel): any { + return { ...ep, date: dayjs(ep.date).format() }; +} + +export function newEventProposal(): EventProposalModel { + return { + name: '', + description: '', + purpose: '', + department: '', + location: '', + owner: '', + date: new Date(), + budgetInDollars: 0, + }; +} diff --git a/web/src/api-client/index.ts b/web/src/api-client/index.ts new file mode 100644 index 0000000..9b50a98 --- /dev/null +++ b/web/src/api-client/index.ts @@ -0,0 +1,52 @@ +import { default as Axios, AxiosInstance } from 'axios'; +import { logService } from '@jdbernard/logging'; + +import { + EventProposalConfig, + eventProposalConfigFromDTO, + EventProposalModel, + eventProposalModelToDTO, +} from './event-proposal.models'; + +export * from './event-proposal.models'; + +const logger = logService.getLogger('client-api'); + +export class HffEntryFormsApiClient { + private http: AxiosInstance; + + private cachedEventProposalConfig: EventProposalConfig | null = null; + + constructor(apiBase: string) { + this.http = Axios.create({ baseURL: apiBase }); + logger.trace('Initialized HffEntryFormsApiClient'); + } + + public async version(): Promise { + const resp = await this.http.get('version'); + return resp.data as string; + } + + public async getEventProposalConfig(): Promise { + if (!this.cachedEventProposalConfig) { + logger.trace('GET /event-proposals/config'); + const resp = await this.http.get('/event-proposals/config'); + this.cachedEventProposalConfig = eventProposalConfigFromDTO( + (resp.data as any).data + ); + } + + return this.cachedEventProposalConfig; + } + + public async proposeEvent(ep: EventProposalModel): Promise { + logger.trace('POST /event-proposals'); + const resp = await this.http.post( + '/event-proposals', + eventProposalModelToDTO(ep) + ); + return resp.status < 300; + } +} + +export default new HffEntryFormsApiClient(process.env.VUE_APP_API_BASE_URL); diff --git a/web/src/api.client.ts b/web/src/api.client.ts new file mode 100644 index 0000000..e69de29 diff --git a/web/src/assets/logo.png b/web/src/assets/logo.png deleted file mode 100644 index f3d2503..0000000 Binary files a/web/src/assets/logo.png and /dev/null differ diff --git a/web/src/assets/welcome-wood.jpg b/web/src/assets/welcome-wood.jpg new file mode 100755 index 0000000..6d1fdbd Binary files /dev/null and b/web/src/assets/welcome-wood.jpg differ diff --git a/web/src/components/HelloWorld.vue b/web/src/components/HelloWorld.vue deleted file mode 100644 index 8a1e447..0000000 --- a/web/src/components/HelloWorld.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - diff --git a/web/src/components/RedirectComponent.vue b/web/src/components/RedirectComponent.vue new file mode 100644 index 0000000..43d0139 --- /dev/null +++ b/web/src/components/RedirectComponent.vue @@ -0,0 +1,16 @@ + + diff --git a/web/src/components/svg/CircleCheckIcon.vue b/web/src/components/svg/CircleCheckIcon.vue new file mode 100755 index 0000000..4515aca --- /dev/null +++ b/web/src/components/svg/CircleCheckIcon.vue @@ -0,0 +1,16 @@ + diff --git a/web/src/components/svg/CircleCrossIcon.vue b/web/src/components/svg/CircleCrossIcon.vue new file mode 100755 index 0000000..199a648 --- /dev/null +++ b/web/src/components/svg/CircleCrossIcon.vue @@ -0,0 +1,16 @@ + diff --git a/web/src/components/svg/HourGlassIcon.vue b/web/src/components/svg/HourGlassIcon.vue new file mode 100755 index 0000000..c8b28af --- /dev/null +++ b/web/src/components/svg/HourGlassIcon.vue @@ -0,0 +1,16 @@ + diff --git a/web/src/components/svg/SpinnerIcon.vue b/web/src/components/svg/SpinnerIcon.vue new file mode 100755 index 0000000..13351e9 --- /dev/null +++ b/web/src/components/svg/SpinnerIcon.vue @@ -0,0 +1,16 @@ + diff --git a/web/src/components/svg/index.ts b/web/src/components/svg/index.ts new file mode 100644 index 0000000..dc8c3b4 --- /dev/null +++ b/web/src/components/svg/index.ts @@ -0,0 +1,4 @@ +export { default as HourGlassIcon } from './HourGlassIcon.vue'; +export { default as SpinnerIcon } from './SpinnerIcon.vue'; +export { default as CircleCheckIcon } from './CircleCheckIcon.vue'; +export { default as CircleCrossIcon } from './CircleCrossIcon.vue'; diff --git a/web/src/main.ts b/web/src/main.ts index 82ac74e..6ac25c4 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,5 +1,13 @@ import { createApp } from 'vue'; +import { logService, LogLevel, ConsoleLogAppender } from '@jdbernard/logging'; + import App from './App.vue'; import router from './router'; +const consoleLogAppender = new ConsoleLogAppender(LogLevel.ALL); +logService.ROOT_LOGGER.appenders.push(consoleLogAppender); + +const logger = logService.getLogger('main'); + createApp(App).use(router).mount('#app'); +logger.trace('App mounted.'); diff --git a/web/src/router.ts b/web/src/router.ts new file mode 100644 index 0000000..c81e48c --- /dev/null +++ b/web/src/router.ts @@ -0,0 +1,24 @@ +import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; +import RedirectComponent from '@/components/RedirectComponent.vue'; +import TheProposeEventView from '@/views/ProposeEvent.vue'; + +const routes: Array = [ + { + path: '/', + name: 'Home', + component: RedirectComponent, + props: { target: 'https://hopefamilyfellowship.com' }, + }, + { + path: '/propose-event', + name: 'ProposeEvent', + component: TheProposeEventView, + }, +]; + +const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes, +}); + +export default router; diff --git a/web/src/router/index.ts b/web/src/router/index.ts deleted file mode 100644 index ab087b4..0000000 --- a/web/src/router/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; -import Home from '../views/Home.vue'; - -const routes: Array = [ - { - path: '/', - name: 'Home', - component: Home, - }, - { - path: '/about', - name: 'About', - // route level code-splitting - // this generates a separate chunk (about.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => - import(/* webpackChunkName: "about" */ '../views/About.vue'), - }, -]; - -const router = createRouter({ - history: createWebHistory(process.env.BASE_URL), - routes, -}); - -export default router; diff --git a/web/src/styles/forSize.mixin.scss b/web/src/styles/forSize.mixin.scss new file mode 100644 index 0000000..11b2e3c --- /dev/null +++ b/web/src/styles/forSize.mixin.scss @@ -0,0 +1,20 @@ +$maxMobileWidth: 640px; +$maxTabletWidth: 1079px; +$ultrawideMinWidth: 1600px; + +// --- mobMaxW --- tabMaxW --------------- ultrawideMinW +// mobile | tablet | desktop | ultrawide + +@mixin forSize($size) { + + @if $size == mobile { + @media screen and (max-width: $maxMobileWidth) { @content; } } + @else if $size == tablet { + @media screen and (min-width: $maxMobileWidth + 1) and (max-width: $maxTabletWidth) { @content; } } + @else if $size == desktop { + @media screen and (min-width: $maxTabletWidth + 1) and (max-width: $ultrawideMinWidth - 1) { @content; } } + @else if $size == ultrawide { + @media screen and (min-width: $ultrawideMinWidth) { @content; } } + @else if $size == notMobile { + @media screen and (min-width: $maxMobileWidth + 1) { @content; } } +} diff --git a/web/src/styles/ui-common.scss b/web/src/styles/ui-common.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/views/About.vue b/web/src/views/About.vue deleted file mode 100644 index 3fa2807..0000000 --- a/web/src/views/About.vue +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue deleted file mode 100644 index 87d2cc7..0000000 --- a/web/src/views/Home.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/web/src/views/ProposeEvent.scss b/web/src/views/ProposeEvent.scss new file mode 100644 index 0000000..e4c6eab --- /dev/null +++ b/web/src/views/ProposeEvent.scss @@ -0,0 +1,113 @@ +@import "~@/styles/forSize.mixin"; + +#event-proposal { + margin: 2em auto; + + header { + background-color: #FFFCFC; + border-bottom: thin solid #aaa; + margin: 0; + padding: 0.5em; + + h1, h2 { + margin-top: 0.5em; + margin-bottom: 0; + padding: 0; + } + + } + + &.success header { background-color: #38b00010; } + &.error header { background-color: #d9042910; } + + form { + display: flex; + flex-direction: column; + align-items: flex-start; + + fieldset { + border: none; + display: flex; + flex-direction: column; + align-items: stretch; + margin: 0.5em; + padding: 0; + width: calc(100% - 1em); + } + } + + label { + margin: 0.5em; + display: flex; + + span { width: 9em; } + input, textarea, select { flex-grow: 1; } + } + + .invalid-message { + display: flex; + margin: 0.5em; + color: #d90429; + font-style: italic; + justify-content: center; + } + + .actions { + display: flex; + justify-content: center; + margin: 0.5em; + + button svg { + position: relative; + margin: 0 0.125em; + top: 0.125em; + } + } + + &.success button { + color: #38b000; + background-color: #38b00010; + border-color: #38b000; + } + + &.error button { + color: #d90429; + background-color: #d9042910; + border-color: #d90429; + } +} + +.successes, .errors { margin: 0.5em auto; } + +.successes { color: #38b000; } +.errors { color: #d90429; } + +@include forSize(mobile) { + #header-splash { + width: 100%; + margin: 0; + } + + #event-proposal { + margin: 1em; + width: 100%; + } +} + +@include forSize(notMobile) { + #header-splash { + object-fit: cover; + object-position: center 56%; + width: 100%; + height: 20em; + } + + #event-proposal { + border: solid thin #bbb; + border-radius: 0.25em; + box-shadow: 0.25em 0.25em 0.75em #aaa; + width: 30em; + } + + .successes, .errors { width: 26em; } +} diff --git a/web/src/views/ProposeEvent.ts b/web/src/views/ProposeEvent.ts new file mode 100644 index 0000000..067c6c1 --- /dev/null +++ b/web/src/views/ProposeEvent.ts @@ -0,0 +1,84 @@ +import { defineComponent, Ref, ref } from 'vue'; +import { logService } from '@jdbernard/logging'; +import { + default as api, + EventProposalModel, + newEventProposal, +} from '@/api-client'; + +import { + CircleCheckIcon, + CircleCrossIcon, + HourGlassIcon, + SpinnerIcon, +} from '@/components/svg'; + +const logger = logService.getLogger('/propose-events'); + +type FormState = + | 'loading' + | 'ready' + | 'submitting' + | 'invalid' + | 'success' + | 'error'; + +export default defineComponent({ + name: 'TheProposeEventView', + props: {}, + components: { CircleCheckIcon, CircleCrossIcon, HourGlassIcon, SpinnerIcon }, + setup: function TheProposeEventView() { + const departments: Ref<{ value: string; color: string }[]> = ref([]); + const formState: Ref = ref('loading'); + + setTimeout(async () => { + departments.value = (await api.getEventProposalConfig()).departments; + formState.value = 'ready'; + }); + + const formVal = { event: newEventProposal() }; + const successes: string[] = []; + const errors: string[] = []; + + function validateEvent(ev: EventProposalModel): boolean { + return ( + !!ev.name && + !!ev.description && + !!ev.purpose && + !!ev.department && + !!ev.location && + !!ev.owner + ); + } + + async function proposeEvent(): Promise { + if (!validateEvent(formVal.event)) { + formState.value = 'invalid'; + return; + } + + formState.value = 'submitting'; + logger.trace({ formState: formState.value }); + if (await api.proposeEvent(formVal.event)) { + formState.value = 'success'; + successes.push( + `We've recorded the proposed details for ${formVal.event.name}.` + ); + } else { + formState.value = 'error'; + errors.push( + 'We were unable to record the proposed details for ' + + formVal.event.name + + ". Poke Jonathan and tell him it's broken." + ); + } + + setTimeout(() => { + formVal.event = newEventProposal(); + formState.value = 'ready'; + }, 5000); + } + + return { departments, errors, formState, formVal, successes, proposeEvent }; + }, +}); diff --git a/web/src/views/ProposeEvent.vue b/web/src/views/ProposeEvent.vue new file mode 100644 index 0000000..d2fa242 --- /dev/null +++ b/web/src/views/ProposeEvent.vue @@ -0,0 +1,89 @@ + + +