web: EntryProposal form implementation.
This commit is contained in:
parent
e055bee0f3
commit
3675c6054a
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 }
|
||||
|
||||
|
1
web/.env
Normal file
1
web/.env
Normal file
@ -0,0 +1 @@
|
||||
VUE_APP_API_BASE_URL=https://forms-api-dev.hopefamilyfellowship.com/v1/
|
1
web/.env.production
Normal file
1
web/.env.production
Normal file
@ -0,0 +1 @@
|
||||
VUE_APP_API_BASE_URL=https://forms-api.hopefamilyfellowship.com/v1/
|
53
web/package-lock.json
generated
53
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
},
|
||||
|
73
web/src/App.scss
Normal file
73
web/src/App.scss
Normal 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; }
|
||||
}
|
@ -1,30 +1,4 @@
|
||||
<template>
|
||||
<div id="nav">
|
||||
<router-link to="/">Home</router-link> |
|
||||
<router-link to="/about">About</router-link>
|
||||
</div>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<style lang="scss" src="./App.scss"></style>
|
||||
|
46
web/src/api-client/event-proposal.models.ts
Normal file
46
web/src/api-client/event-proposal.models.ts
Normal 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,
|
||||
};
|
||||
}
|
52
web/src/api-client/index.ts
Normal file
52
web/src/api-client/index.ts
Normal 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
0
web/src/api.client.ts
Normal file
Binary file not shown.
Before Width: | Height: | Size: 6.7 KiB |
BIN
web/src/assets/welcome-wood.jpg
Executable file
BIN
web/src/assets/welcome-wood.jpg
Executable file
Binary file not shown.
After Width: | Height: | Size: 217 KiB |
@ -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>
|
16
web/src/components/RedirectComponent.vue
Normal file
16
web/src/components/RedirectComponent.vue
Normal 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>
|
16
web/src/components/svg/CircleCheckIcon.vue
Executable file
16
web/src/components/svg/CircleCheckIcon.vue
Executable 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>
|
16
web/src/components/svg/CircleCrossIcon.vue
Executable file
16
web/src/components/svg/CircleCrossIcon.vue
Executable 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>
|
16
web/src/components/svg/HourGlassIcon.vue
Executable file
16
web/src/components/svg/HourGlassIcon.vue
Executable 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>
|
16
web/src/components/svg/SpinnerIcon.vue
Executable file
16
web/src/components/svg/SpinnerIcon.vue
Executable 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>
|
4
web/src/components/svg/index.ts
Normal file
4
web/src/components/svg/index.ts
Normal 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';
|
@ -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.');
|
||||
|
24
web/src/router.ts
Normal file
24
web/src/router.ts
Normal 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;
|
@ -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;
|
20
web/src/styles/forSize.mixin.scss
Normal file
20
web/src/styles/forSize.mixin.scss
Normal 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; } }
|
||||
}
|
0
web/src/styles/ui-common.scss
Normal file
0
web/src/styles/ui-common.scss
Normal file
@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
@ -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>
|
113
web/src/views/ProposeEvent.scss
Normal file
113
web/src/views/ProposeEvent.scss
Normal 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; }
|
||||
}
|
84
web/src/views/ProposeEvent.ts
Normal file
84
web/src/views/ProposeEvent.ts
Normal 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 };
|
||||
},
|
||||
});
|
89
web/src/views/ProposeEvent.vue
Normal file
89
web/src/views/ProposeEvent.vue
Normal 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>
|
Loading…
Reference in New Issue
Block a user