web: EntryProposal form implementation.

This commit is contained in:
Jonathan Bernard 2021-10-26 01:46:59 -05:00
parent e055bee0f3
commit 3675c6054a
32 changed files with 705 additions and 203 deletions

View File

@ -1,6 +1,6 @@
{ {
"eventParentId": "ffff5ca5cb0547b6a99fb78cdb9b0170", "eventParentId": "ffff5ca5cb0547b6a99fb78cdb9b0170",
"knownOrigins": [ "http://curl.localhost" ], "knownOrigins": [ "http://localhost:8080", "http://curl.localhost" ],
"notionApiBaseUrl": "https://api.notion.com/v1", "notionApiBaseUrl": "https://api.notion.com/v1",
"notionVersion": "2021-08-16", "notionVersion": "2021-08-16",
"port": 8300 "port": 8300

View File

@ -126,9 +126,15 @@ proc start*(cfg: HffEntryFormsApiConfig): void =
get "/version": get "/version":
jsonResp(Http200, $(%("hff_entry_forms_api v" & HFF_ENTRY_FORMS_API_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: withApiErrors:
let ep = parseEventProposal(parseJson(request.body)) let ep = parseEventProposal(parseJson(request.body))
if createProposedEvent(cfg, ep): statusResp(Http200) if createProposedEvent(cfg, ep): statusResp(Http200)

View File

@ -10,6 +10,11 @@ type
date*: DateTime date*: DateTime
budgetInDollars*: int budgetInDollars*: int
MultiSelectOption = tuple[value: string, color: string]
EventProposalConfig* = object
departmentOptions*: seq[MultiSelectOption]
proc getOrFail(n: JsonNode, key: string): JsonNode = proc getOrFail(n: JsonNode, key: string): JsonNode =
## convenience method to get a key from a JObject or raise an exception ## convenience method to get a key from a JObject or raise an exception
if not n.hasKey(key): 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
}

View File

@ -1,16 +1,43 @@
import json, logging, std/httpclient, strutils import json, logging, std/httpclient, sequtils, strutils
import ./models, ./service import ./models, ./service
proc createProposedEvent*(cfg: HffEntryFormsApiConfig, ep: EventProposal): bool = proc makeHttpClient(cfg: HffEntryFormsApiConfig): HttpClient =
let headers = newHttpHeaders([ let headers = newHttpHeaders([
("Content-Type", "application/json"), ("Content-Type", "application/json"),
("Authorization", "Bearer " & cfg.integrationToken), ("Authorization", "Bearer " & cfg.integrationToken),
("Notion-Version", cfg.notionVersion) ("Notion-Version", cfg.notionVersion)
], true) ], true)
let http = newHttpClient(headers = headers, )
debug $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 let epNotionPage = ep.asNotionPage
epNotionPage["parent"] = %*{ "database_id": cfg.eventParentId } epNotionPage["parent"] = %*{ "database_id": cfg.eventParentId }

1
web/.env Normal file
View File

@ -0,0 +1 @@
VUE_APP_API_BASE_URL=https://forms-api-dev.hopefamilyfellowship.com/v1/

1
web/.env.production Normal file
View File

@ -0,0 +1 @@
VUE_APP_API_BASE_URL=https://forms-api.hopefamilyfellowship.com/v1/

53
web/package-lock.json generated
View File

@ -85,6 +85,40 @@
"postcss": "^7.0.0" "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": { "@mrmlnc/readdir-enhanced": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
@ -1697,6 +1731,14 @@
"integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==",
"dev": true "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": { "babel-code-frame": {
"version": "6.26.0", "version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz",
@ -3367,6 +3409,11 @@
"assert-plus": "^1.0.0" "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": { "debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -4772,8 +4819,7 @@
"follow-redirects": { "follow-redirects": {
"version": "1.14.4", "version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==", "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
"dev": true
}, },
"for-in": { "for-in": {
"version": "1.0.2", "version": "1.0.2",
@ -6916,8 +6962,7 @@
"ms": { "ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
"dev": true
}, },
"multicast-dns": { "multicast-dns": {
"version": "6.2.3", "version": "6.2.3",

View File

@ -5,10 +5,14 @@
"scripts": { "scripts": {
"serve": "npx servor dist", "serve": "npx servor dist",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"build-local": "vue-cli-service build --mode development",
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint",
"vue-serve": "vue-cli-service serve" "vue-serve": "vue-cli-service serve"
}, },
"dependencies": { "dependencies": {
"@jdbernard/logging": "^1.1.3",
"axios": "^0.23.0",
"dayjs": "^1.10.7",
"vue": "^3.0.0", "vue": "^3.0.0",
"vue-router": "^4.0.0-0" "vue-router": "^4.0.0-0"
}, },

73
web/src/App.scss Normal file
View File

@ -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; }
}

View File

@ -1,30 +1,4 @@
<template> <template>
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view /> <router-view />
</template> </template>
<style lang="scss" src="./App.scss"></style>
<style lang="scss">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
#nav {
padding: 30px;
a {
font-weight: bold;
color: #2c3e50;
&.router-link-exact-active {
color: #42b983;
}
}
}
</style>

View File

@ -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<MutableEventProposalModel>;
export type EventProposalConfig = Readonly<MutableEventProposalConfig>;
// 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,
};
}

View File

@ -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<string> {
const resp = await this.http.get('version');
return resp.data as string;
}
public async getEventProposalConfig(): Promise<EventProposalConfig> {
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<boolean> {
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);

0
web/src/api.client.ts Normal file
View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

BIN
web/src/assets/welcome-wood.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

View File

@ -1,116 +0,0 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br />
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener"
>vue-cli documentation</a
>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
target="_blank"
rel="noopener"
>typescript</a
>
</li>
<li>
<a
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
target="_blank"
rel="noopener"
>eslint</a
>
</li>
</ul>
<h3>Essential Links</h3>
<ul>
<li>
<a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
</li>
<li>
<a href="https://forum.vuejs.org" target="_blank" rel="noopener"
>Forum</a
>
</li>
<li>
<a href="https://chat.vuejs.org" target="_blank" rel="noopener"
>Community Chat</a
>
</li>
<li>
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
>Twitter</a
>
</li>
<li>
<a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
</li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li>
<a href="https://router.vuejs.org" target="_blank" rel="noopener"
>vue-router</a
>
</li>
<li>
<a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
</li>
<li>
<a
href="https://github.com/vuejs/vue-devtools#vue-devtools"
target="_blank"
rel="noopener"
>vue-devtools</a
>
</li>
<li>
<a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
>vue-loader</a
>
</li>
<li>
<a
href="https://github.com/vuejs/awesome-vue"
target="_blank"
rel="noopener"
>awesome-vue</a
>
</li>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'HelloWorld',
props: {
msg: String,
},
});
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View File

@ -0,0 +1,16 @@
<template>Redirecting...</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
target: {
type: String,
required: true,
},
},
setup: function RedirectComponent(props) {
window.location.assign(props.target);
},
});
</script>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="1em"
height="1em"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.49991 0.877045C3.84222 0.877045 0.877075 3.84219 0.877075 7.49988C0.877075 11.1575 3.84222 14.1227 7.49991 14.1227C11.1576 14.1227 14.1227 11.1575 14.1227 7.49988C14.1227 3.84219 11.1576 0.877045 7.49991 0.877045ZM1.82708 7.49988C1.82708 4.36686 4.36689 1.82704 7.49991 1.82704C10.6329 1.82704 13.1727 4.36686 13.1727 7.49988C13.1727 10.6329 10.6329 13.1727 7.49991 13.1727C4.36689 13.1727 1.82708 10.6329 1.82708 7.49988ZM10.1589 5.53774C10.3178 5.31191 10.2636 5.00001 10.0378 4.84109C9.81194 4.68217 9.50004 4.73642 9.34112 4.96225L6.51977 8.97154L5.35681 7.78706C5.16334 7.59002 4.84677 7.58711 4.64973 7.78058C4.45268 7.97404 4.44978 8.29061 4.64325 8.48765L6.22658 10.1003C6.33054 10.2062 6.47617 10.2604 6.62407 10.2483C6.77197 10.2363 6.90686 10.1591 6.99226 10.0377L10.1589 5.53774Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="1em"
height="1em"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.877075 7.49988C0.877075 3.84219 3.84222 0.877045 7.49991 0.877045C11.1576 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1576 14.1227 7.49991 14.1227C3.84222 14.1227 0.877075 11.1575 0.877075 7.49988ZM7.49991 1.82704C4.36689 1.82704 1.82708 4.36686 1.82708 7.49988C1.82708 10.6329 4.36689 13.1727 7.49991 13.1727C10.6329 13.1727 13.1727 10.6329 13.1727 7.49988C13.1727 4.36686 10.6329 1.82704 7.49991 1.82704ZM9.85358 5.14644C10.0488 5.3417 10.0488 5.65829 9.85358 5.85355L8.20713 7.49999L9.85358 9.14644C10.0488 9.3417 10.0488 9.65829 9.85358 9.85355C9.65832 10.0488 9.34173 10.0488 9.14647 9.85355L7.50002 8.2071L5.85358 9.85355C5.65832 10.0488 5.34173 10.0488 5.14647 9.85355C4.95121 9.65829 4.95121 9.3417 5.14647 9.14644L6.79292 7.49999L5.14647 5.85355C4.95121 5.65829 4.95121 5.3417 5.14647 5.14644C5.34173 4.95118 5.65832 4.95118 5.85358 5.14644L7.50002 6.79289L9.14647 5.14644C9.34173 4.95118 9.65832 4.95118 9.85358 5.14644Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="1em"
height="1em"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.999878 0.5C0.999878 0.223858 1.22374 0 1.49988 0H13.4999C13.776 0 13.9999 0.223858 13.9999 0.5C13.9999 0.776142 13.776 1 13.4999 1L9 1V5C9 5.55228 8.55228 6 8 6H7C6.44772 6 6 5.55228 6 5V1H1.49988C1.22374 1 0.999878 0.776142 0.999878 0.5ZM7 9C6.44772 9 6 9.44771 6 10V14H1.49988C1.22374 14 0.999878 14.2239 0.999878 14.5C0.999878 14.7761 1.22374 15 1.49988 15H13.4999C13.776 15 13.9999 14.7761 13.9999 14.5C13.9999 14.2239 13.776 14 13.4999 14H9V10C9 9.44772 8.55228 9 8 9H7Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="1em"
height="1em"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.90321 7.29677C1.90321 10.341 4.11041 12.4147 6.58893 12.8439C6.87255 12.893 7.06266 13.1627 7.01355 13.4464C6.96444 13.73 6.69471 13.9201 6.41109 13.871C3.49942 13.3668 0.86084 10.9127 0.86084 7.29677C0.860839 5.76009 1.55996 4.55245 2.37639 3.63377C2.96124 2.97568 3.63034 2.44135 4.16846 2.03202L2.53205 2.03202C2.25591 2.03202 2.03205 1.80816 2.03205 1.53202C2.03205 1.25588 2.25591 1.03202 2.53205 1.03202L5.53205 1.03202C5.80819 1.03202 6.03205 1.25588 6.03205 1.53202L6.03205 4.53202C6.03205 4.80816 5.80819 5.03202 5.53205 5.03202C5.25591 5.03202 5.03205 4.80816 5.03205 4.53202L5.03205 2.68645L5.03054 2.68759L5.03045 2.68766L5.03044 2.68767L5.03043 2.68767C4.45896 3.11868 3.76059 3.64538 3.15554 4.3262C2.44102 5.13021 1.90321 6.10154 1.90321 7.29677ZM13.0109 7.70321C13.0109 4.69115 10.8505 2.6296 8.40384 2.17029C8.12093 2.11718 7.93465 1.84479 7.98776 1.56188C8.04087 1.27898 8.31326 1.0927 8.59616 1.14581C11.4704 1.68541 14.0532 4.12605 14.0532 7.70321C14.0532 9.23988 13.3541 10.4475 12.5377 11.3662C11.9528 12.0243 11.2837 12.5586 10.7456 12.968L12.3821 12.968C12.6582 12.968 12.8821 13.1918 12.8821 13.468C12.8821 13.7441 12.6582 13.968 12.3821 13.968L9.38205 13.968C9.10591 13.968 8.88205 13.7441 8.88205 13.468L8.88205 10.468C8.88205 10.1918 9.10591 9.96796 9.38205 9.96796C9.65819 9.96796 9.88205 10.1918 9.88205 10.468L9.88205 12.3135L9.88362 12.3123C10.4551 11.8813 11.1535 11.3546 11.7585 10.6738C12.4731 9.86976 13.0109 8.89844 13.0109 7.70321Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -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';

View File

@ -1,5 +1,13 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import { logService, LogLevel, ConsoleLogAppender } from '@jdbernard/logging';
import App from './App.vue'; import App from './App.vue';
import router from './router'; 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'); createApp(App).use(router).mount('#app');
logger.trace('App mounted.');

24
web/src/router.ts Normal file
View File

@ -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<RouteRecordRaw> = [
{
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;

View File

@ -1,26 +0,0 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import Home from '../views/Home.vue';
const routes: Array<RouteRecordRaw> = [
{
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;

View File

@ -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; } }
}

View File

View File

@ -1,5 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

View File

@ -1,18 +0,0 @@
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png" />
<HelloWorld msg="Welcome to Your Vue.js App" />
</div>
</template>
<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue';
export default {
name: 'Home',
components: {
HelloWorld,
},
};
</script>

View File

@ -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; }
}

View File

@ -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<FormState> = 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<void> {
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 };
},
});

View File

@ -0,0 +1,89 @@
<template>
<img id="header-splash" src="../assets/welcome-wood.jpg" />
<div id="event-proposal" :class="[formState]">
<header>
<h1>Propose an Event</h1>
<h2>Hope Family Fellowship</h2>
</header>
<form @submit.prevent="proposeEvent">
<fieldset :disabled="formState !== 'ready' && formState !== 'invalid'">
<label>
<span>Event Name</span>
<input
type="text"
name="name"
placeholder="e.g. Men's Bible Study"
v-model="formVal.event.name"
/>
</label>
<label>
<span>Date and time</span>
<input type="date" name="date" v-model="formVal.event.date" />
</label>
<label>
<span>Department</span>
<select name="department" v-model="formVal.event.department">
<option value="">--- select a department ---</option>
<option
v-for="opt in departments"
:key="opt.value"
class="color-{{opt.color}}"
>
{{ opt.value }}
</option>
</select>
</label>
<label>
<span>Owner</span>
<input type="text" name="owner" v-model="formVal.event.owner" />
</label>
<label>
<span>Location</span>
<textarea v-model="formVal.event.location"></textarea>
</label>
<label>
<span>Purpose</span>
<textarea v-model="formVal.event.purpose"></textarea>
</label>
<label>
<span>Description</span>
<textarea v-model="formVal.event.description"></textarea>
</label>
<div class="invalid-message" v-if="formState === 'invalid'">
All fields are required.
</div>
<div class="actions">
<button type="submit">
<span v-if="formState === 'ready' || formState === 'invalid'"
>Propose Event</span
>
<span v-if="formState === 'submitting'">
<SpinnerIcon class="spin" />
submitting...
</span>
<span v-if="formState === 'loading'">
<HourGlassIcon class="tumble" />
Loading...
</span>
<span v-if="formState === 'success'">
<CircleCheckIcon />
Event Proposed!
</span>
<span v-if="formState === 'error'">
<CircleCheckIcon />
An error occurred.
</span>
</button>
</div>
</fieldset>
</form>
</div>
<div class="successes">
<div v-for="s in successes" :key="s">{{ s }}</div>
</div>
<div class="errors">
<div v-for="e in errors" :key="e">{{ s }}</div>
</div>
</template>
<script lang="ts" src="./ProposeEvent.ts"></script>
<style scoped lang="scss" src="./ProposeEvent.scss"></style>