5 Commits
0.1.0 ... 0.2.0

52 changed files with 1446 additions and 176 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.DS_Store
.terraform
node_modules
/web/dist

32
Makefile Executable file
View File

@ -0,0 +1,32 @@
VERSION:=$(shell git describe --always)
TARGET_ENV ?= dev
build: dist/hff-entry-forms-api.tar.gz dist/hff-entry-forms-web.tar.gz
clean:
-rm -r dist
-rm -r web/dist
-docker container prune
-docker image prune
update-version:
operations/update-version.sh
dist/hff-entry-forms-web.tar.gz:
-mkdir dist
TARGET_ENV=$(TARGET_ENV) make -C web build
tar czf dist/hff-entry-forms-web-${VERSION}.tar.gz -C web/dist .
cp dist/hff-entry-forms-web-${VERSION}.tar.gz dist/hff-entry-forms-web.tar.gz
deploy-api:
make -C api build-image push-image
cd operations/terraform && terraform apply -target module.${TARGET_ENV}_env.aws_ecs_task_definition.hff_entry_forms_api -target module.${TARGET_ENV}_env.aws_ecs_service.hff_entry_forms_api
deploy-web: dist/hff-entry-forms-web.tar.gz
mkdir -p temp-deploy/hff-entry-forms-web-${VERSION}
tar xzf dist/hff-entry-forms-web-${VERSION}.tar.gz -C temp-deploy/hff-entry-forms-web-${VERSION}
aws s3 sync temp-deploy/hff-entry-forms-web-${VERSION} s3://forms.hopefamilyfellowship.com/$(TARGET_ENV)/webroot
TARGET_ENV=${TARGET_ENV} operations/invalidate-cdn-cache.sh
rm -r temp-deploy
deploy: deploy-api deploy-web

20
api/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM 063932952339.dkr.ecr.us-west-2.amazonaws.com/alpine-nim:nim-1.4.8 AS build
MAINTAINER jonathan@jdbernard.com
COPY hff_entry_forms_api.nimble /hff_entry_forms_api/
COPY src /hff_entry_forms_api/src
WORKDIR /hff_entry_forms_api
RUN nimble build -y
FROM alpine
EXPOSE 80
RUN apk -v --update add --no-cache \
ca-certificates \
libcrypto1.1 \
libssl1.1 \
pcre \
postgresql-client
COPY --from=build /hff_entry_forms_api/hff_entry_forms_api /
COPY hff_entry_forms_api.config.docker.json /hff_entry_forms_api.config.json
CMD ["/hff_entry_forms_api", "serve"]

81
api/Makefile Normal file
View File

@ -0,0 +1,81 @@
SOURCES=$(wildcard src/*.nim) $(wildcard src/hff_entry_forms_apipkg/*.nim)
# AWS Account URL for the ECR repository
ECR_ACCOUNT_URL ?= 063932952339.dkr.ecr.us-west-2.amazonaws.com
# The port on the host machine (not the container)
PORT ?= 8300
# The Notion integration token.
AUTH_SECRET ?= 123abc
VERSION ?=`git describe`
default: serve-docker
# Running the API locally on bare metal
# -------------------------------------
hff_entry_forms_api: $(SOURCES)
nimble build
# Run the API on this machine. Note that configuration is taken by default
# from the `hff_entry_forms_api.config.json` file, but environment variables
# specified when running make can be used to override these (to change the
# INTEGRATION_TOKEN, for example).
serve: hff_entry_forms_api
./hff_entry_forms_api serve --debug
# Run tests
# ---------
#unittest:
# nim c -r src/unittest/nim/runner
#
#functest: DB_NAME = live_budget_test
#functest: DB_CONFIG = test
#functest: hff_entry_forms_api start-postgres
# echo "\n--------" >> functest-api-server.log
# ./hff_entry_forms_api serve --config hff_entry_forms_api.config.test.json >> functest-api-server.log &
# -nim c -r src/functest/nim/runner "$(TEST_NAME)"
# curl -s -X POST localhost:$(PORT)/api/v1/control/shutdown | jq .
# db_migrate down -c database-$(DB_CONFIG).json
# PGPASSWORD=password psql -p 5500 -U postgres -h localhost -c "DROP DATABASE $(DB_NAME);"
# Building and deploying the API container image
# ----------------------------------------------
# Build the container image.
build-image: $(SOURCES)
docker image build -t $(ECR_ACCOUNT_URL)/hff_entry_forms_api:$(VERSION) .
# Push the container image to the private AWS ECR
push-image: build-image
docker push $(ECR_ACCOUNT_URL)/hff_entry_forms_api:$(VERSION)
# Running locally in a container
# --------------------------------------
# Run the API in a docker container. Note that the configuration loaded into
# the Docker container defines very little of the actual configuration as
# environment variables are used in the deployed environments. Accordingly,
# we must specify them explicitly here.
serve-docker: build-image
docker run \
-e INTEGRATION_TOKEN=$(INTEGRATION_TOKEN) \
-e PORT=80 \
-p 127.0.0.1:$(PORT):80/tcp \
$(ECR_ACCOUNT_URL)/hff_entry_forms_api:$(VERSION)
# Utility
# -------
# Authenticate docker to the AWS private elastic container repository.
ecr-auth:
aws ecr get-login-password --region us-west-2 | docker login --username AWS --password-stdin $(ECR_ACCOUNT_URL)
echo-vars:
@echo \
" ECR_ACCOUNT_URL=$(ECR_ACCOUNT_URL)\n" \
"VERSION=$(VERSION)\n" \
"PORT=$(PORT)\n" \
"INTEGRATION_TOKEN=$(INTEGRATION_TOKEN)\n"

View File

@ -0,0 +1,7 @@
{
"eventParentId": "ffff5ca5cb0547b6a99fb78cdb9b0170",
"knownOrigins": [ "https://forms.hopefamilyfellowship.com" ],
"notionApiBaseUrl": "https://api.notion.com/v1",
"notionVersion": "2021-08-16",
"port": 80
}

View File

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

View File

@ -1,6 +1,6 @@
# Package
version = "0.1.0"
version = "0.2.0"
author = "Jonathan Bernard"
description = "Hope Family Fellowship entry forms."
license = "GPL-3.0-or-later"

View File

@ -26,7 +26,7 @@ proc loadConfig(args: Table[string, docopt.Value]): HffEntryFormsApiConfig =
let cfg = CombinedConfig(docopt: args, json: json)
result = HffEntryFormsApiConfig(
debug: parseBool(cfg.getVal("debug")),
debug: args["--debug"],
eventParentId: cfg.getVal("event-parent-id"),
integrationToken: cfg.getVal("integration-token"),
knownOrigins: cfg.getVal("known-origins")[1..^2].split(',').mapIt(it[1..^2]),

View File

@ -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)

View File

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

View File

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

View File

@ -1 +1 @@
const HFF_ENTRY_FORMS_API_VERSION* = "0.1.0"
const HFF_ENTRY_FORMS_API_VERSION* = "0.2.0"

11
operations/README.md Normal file
View File

@ -0,0 +1,11 @@
# HFF Entry Forms - DevOps
## System Components
* DNS - hosted on GoDaddy
* API Server - hosted on the `ortis` ECS cluster at JDB Software
* API Loadbalancer - using the main load balancer for JDB Software
* Web App - Served by CloudFront from an S3 bucket managed by JDB Software
* Certificates - Manually created and validated for \*.HFF.com
* Notion Integration - defined in Notion, token provided via AWS secrets
manager to the API instance running on the ortis cluster.

View File

@ -0,0 +1,21 @@
### Variables
variable "aws_region" {
description = "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html"
default = "us-west-2" # Oregon
}
variable "app_root_url" {
description = "Name of the S3 bucket to store deployed artifacts, logs, etc."
default = "forms.hopefamilyfellowship.com"
}
variable "cloudfront_certificate_arn" {
description = "Name of the certificate to use for CloudFront distributions (must be in us-east-1)."
default = "arn:aws:acm:us-east-1:063932952339:certificate/8e4b4a05-d61e-49af-b7e9-8e59999f197a"
}
variable "api_certificate_arn" {
description = "Name of the certificate to use for the API load balancer (must be in the same region as the loadbalancer)."
default = "arn:aws:acm:us-west-2:063932952339:certificate/04c33fd7-a6b0-4f58-8e8a-fddbe361aa85"
}

View File

@ -0,0 +1,99 @@
data "aws_iam_policy_document" "bucket_access_policy" {
statement {
actions = [ "s3:GetObject" ]
effect = "Allow"
resources = [ "${var.artifact_bucket.arn}/${var.environment}/webroot/*" ]
principals {
type = "AWS"
identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ]
}
}
statement {
actions = [ "s3:ListBucket" ]
effect = "Allow"
resources = [ var.artifact_bucket.arn ]
principals {
type = "AWS"
identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ]
}
}
}
output "oai_access_policy" {
value = data.aws_iam_policy_document.bucket_access_policy
}
resource "aws_cloudfront_origin_access_identity" "origin_access_identity" {
comment = "OAI for HFF Entry Forms {$var.environment} environment."
}
resource "aws_cloudfront_distribution" "s3_distribution" {
origin {
domain_name = var.artifact_bucket.bucket_regional_domain_name
origin_id = "S3-HffEntryForms-${var.environment}"
origin_path = "/${var.environment}/webroot"
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.origin_access_identity.cloudfront_access_identity_path
}
}
enabled = true
is_ipv6_enabled = true
comment = "HFF Entry Forms ${var.environment} distribution."
default_root_object = "/index.html"
logging_config {
include_cookies = false
bucket = var.artifact_bucket.bucket_domain_name
prefix = "${var.environment}/logs/cloudfront"
}
aliases = [local.app_domain_name]
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD", "OPTIONS"]
target_origin_id = "S3-HffEntryForms-${var.environment}"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 60 * 60 * 24 * 365 # cache for a year
max_ttl = 60 * 60 * 24 * 365 # cache for a year
compress = true
viewer_protocol_policy = "redirect-to-https"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
price_class = "PriceClass_100" # US and Canada only
restrictions {
geo_restriction {
restriction_type = "none"
}
}
tags = {
Environment = local.environment_name
}
viewer_certificate {
# TODO
acm_certificate_arn = var.cloudfront_certificate_arn
ssl_support_method = "sni-only"
}
}

View File

@ -0,0 +1,70 @@
resource "aws_secretsmanager_secret" "hff_entry_forms_api" {
name = "${local.environment_name}-Config"
tags = { Environment = local.environment_name }
}
resource "aws_ecs_task_definition" "hff_entry_forms_api" {
family = local.environment_name
network_mode = "bridge"
requires_compatibilities = ["EC2"]
execution_role_arn = aws_iam_role.ecs_task.arn
# See https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html
container_definitions = jsonencode([
{
name = local.environment_name
image = "${var.ecr_repo.repository_url}:${data.external.git_describe.result.version}"
cpu = 128
memory = 128
memoryReservation = 32
environment = [
{
name = "PORT"
value = "80"
}
]
portMappings = [
{
protocol = "tcp"
containerPort = 80
}
]
secrets = [
{
name = "INTEGRATION_TOKEN"
description = "Connection string with user credentials."
valueFrom = "${aws_secretsmanager_secret.hff_entry_forms_api.arn}:integrationToken::"
},
{
name = "KNOWN_ORIGINS"
description = "Connection string with user credentials."
valueFrom = "${aws_secretsmanager_secret.hff_entry_forms_api.arn}:knownOrigins::"
}
]
}
])
tags = {
Name = local.api_domain_name
Environment = local.environment_name
}
}
resource "aws_ecs_service" "hff_entry_forms_api" {
name = local.environment_name
cluster = data.terraform_remote_state.jdbsoft.outputs.aws_ecs_cluster_ortis.id
task_definition = aws_ecs_task_definition.hff_entry_forms_api.arn
desired_count = 1
launch_type = "EC2"
load_balancer {
target_group_arn = aws_lb_target_group.hff_entry_forms_api.arn
container_name = local.environment_name
container_port = 80
}
tags = {
Name = local.api_domain_name
Environment = local.environment_name
}
}

View File

@ -0,0 +1,69 @@
resource "aws_iam_role" "ecs_task" {
name = "${local.environment_name}-EcsTaskRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Sid = ""
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
inline_policy {
name = "AllowSecretsAccessForHffEntryFormsApiTasks"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
"kms:Decrypt"
]
Resource = [
aws_secretsmanager_secret.hff_entry_forms_api.arn
]
}
]
})
}
inline_policy {
name = "AllowAccessToEcrForHffEntryFormsApiTasks"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken"
]
Resource = [ "*" ]
},
{
Effect = "Allow"
Action = [
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability",
"ecr:DescribeImages",
"ecr:GetDownloadUrlForLayer"
]
Resource = [
var.ecr_repo.arn
]
}
]
})
}
tags = {
Name = "HffEntryForms-EcsTaskRole"
Environment = local.environment_name
}
}

View File

@ -0,0 +1,43 @@
resource "aws_lb_target_group" "hff_entry_forms_api" {
name = "${local.environment_name}-${substr(uuid(), 0, 2)}"
port = 80
protocol = "HTTP"
target_type = "instance"
vpc_id = data.terraform_remote_state.jdbsoft.outputs.aws_vpc_jdbsoft.id
health_check {
enabled = true
matcher = "200"
path = "/v1/version"
}
lifecycle {
create_before_destroy = true
ignore_changes = [name]
}
tags = {
Name = local.api_domain_name
Environment = local.environment_name
}
}
resource "aws_lb_listener_rule" "hff_entry_forms_api" {
listener_arn = data.terraform_remote_state.jdbsoft.outputs.aws_lb_listener_https.arn
action {
type = "forward"
target_group_arn = aws_lb_target_group.hff_entry_forms_api.arn
}
condition {
host_header {
values = [ local.api_domain_name ]
}
}
tags = {
Name = "${local.api_domain_name} HTTPS"
Environment = local.environment_name
}
}

View File

@ -0,0 +1,42 @@
### Variables
variable "environment" {
description = "The short name of this deployed environment. For example: 'dev' or 'prod'. This short name will be used to name resources (CloudFront distributions, etc.)"
}
variable "artifact_bucket" {
description = "The aws_s3_bucket object representing the artifact bucket where deployed artifacts, logs, etc. live."
}
variable "ecr_repo" {
description = "ECR repository information."
}
variable "api_certificate_arn" {
description = "ARN of the certificate to use for the API loadbalancer."
}
variable "cloudfront_certificate_arn" {
description = "ARN of the certificate to use for CloudFront."
}
locals {
environment_name = "HffEntryForms-${var.environment}"
app_domain_name = "forms${var.environment == "prod" ? "" : "-${var.environment}"}.hopefamilyfellowship.com"
api_domain_name = "forms-api${var.environment == "prod" ? "" : "-${var.environment}"}.hopefamilyfellowship.com"
}
data "external" "git_describe" {
program = ["sh", "-c", "git describe | xargs printf '{\"version\": \"%s\"}'"]
}
data "terraform_remote_state" "jdbsoft" {
backend = "s3"
config = {
bucket = "operations.jdb-software.com"
region = "us-west-2"
key = "terraform/operations.tfstate"
dynamodb_table = "terraform-state-lock.jdb-software.com"
}
}

View File

@ -0,0 +1,8 @@
resource "aws_ecr_repository" "hff_entry_forms_api" {
name = "hff_entry_forms_api"
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}

View File

@ -0,0 +1,38 @@
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "hff_entry_forms" {
bucket = var.app_root_url
acl = "log-delivery-write"
}
module "dev_env" {
source = "./deployed_env"
environment = "dev"
api_certificate_arn = var.api_certificate_arn
artifact_bucket = aws_s3_bucket.hff_entry_forms
cloudfront_certificate_arn = var.cloudfront_certificate_arn
ecr_repo = aws_ecr_repository.hff_entry_forms_api
}
module "prod_env" {
source = "./deployed_env"
environment = "prod"
api_certificate_arn = var.api_certificate_arn
artifact_bucket = aws_s3_bucket.hff_entry_forms
cloudfront_certificate_arn = var.cloudfront_certificate_arn
ecr_repo = aws_ecr_repository.hff_entry_forms_api
}
data "aws_iam_policy_document" "cloudfront_access_policy" {
source_json = "${module.dev_env.oai_access_policy.json}"
override_json = "${module.prod_env.oai_access_policy.json}"
}
resource "aws_s3_bucket_policy" "hff_entry_forms" {
bucket = aws_s3_bucket.hff_entry_forms.id
policy = data.aws_iam_policy_document.cloudfront_access_policy.json
}

View File

@ -0,0 +1,8 @@
terraform {
backend "s3" {
bucket = "forms.hopefamilyfellowship.com"
region = "us-west-2"
key = "terraform.tfstate"
dynamodb_table = "terraform-state-lock.jdb-software.com"
}
}

View File

View File

@ -0,0 +1,88 @@
{
"version": 4,
"terraform_version": "0.13.1",
"serial": 3,
"lineage": "a0c8b19d-5dd4-8895-bb16-f9d47e764e93",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "aws_ecr_repository",
"name": "hff_entry_forms",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"arn": "arn:aws:ecr:us-west-2:063932952339:repository/hff_entry_forms",
"encryption_configuration": [
{
"encryption_type": "AES256",
"kms_key": ""
}
],
"id": "hff_entry_forms",
"image_scanning_configuration": [
{
"scan_on_push": true
}
],
"image_tag_mutability": "IMMUTABLE",
"name": "hff_entry_forms",
"registry_id": "063932952339",
"repository_url": "063932952339.dkr.ecr.us-west-2.amazonaws.com/hff_entry_forms",
"tags": null,
"tags_all": {},
"timeouts": null
},
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjoxMjAwMDAwMDAwMDAwfX0="
}
]
},
{
"mode": "managed",
"type": "aws_s3_bucket",
"name": "hff_entry_forms",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"acceleration_status": "",
"acl": "log-delivery-write",
"arn": "arn:aws:s3:::forms.hopefamilyfellowship.com",
"bucket": "forms.hopefamilyfellowship.com",
"bucket_domain_name": "forms.hopefamilyfellowship.com.s3.amazonaws.com",
"bucket_prefix": null,
"bucket_regional_domain_name": "forms.hopefamilyfellowship.com.s3.us-west-2.amazonaws.com",
"cors_rule": [],
"force_destroy": false,
"grant": [],
"hosted_zone_id": "Z3BJ6K6RIION7M",
"id": "forms.hopefamilyfellowship.com",
"lifecycle_rule": [],
"logging": [],
"object_lock_configuration": [],
"policy": null,
"region": "us-west-2",
"replication_configuration": [],
"request_payer": "BucketOwner",
"server_side_encryption_configuration": [],
"tags": null,
"tags_all": {},
"versioning": [
{
"enabled": false,
"mfa_delete": false
}
],
"website": [],
"website_domain": null,
"website_endpoint": null
},
"private": "bnVsbA=="
}
]
}
]
}

63
operations/update-version.sh Executable file
View File

@ -0,0 +1,63 @@
#!/bin/bash
#
# Script to update the version number, commit the changes to the version files,
# and tag the new commit.
set -e
origDir=$(pwd)
rootDir=$(git rev-parse --show-toplevel)
cd "$rootDir"
currentBranch=$(git rev-parse --abbrev-ref HEAD)
if [ "$currentBranch" != "develop" ]; then
printf "You are currently on the '%s' branch. Is this intended (yes/no)? " "$currentBranch"
read -r confirmation
if [ "$confirmation" != "yes" ]; then exit 1; fi
fi
lastVersion=$(jq -r .version web/package.json)
printf "Last version: %s\n" "$lastVersion"
printf "New version: "
read -r newVersion
printf "New version will be \"%s\". Is this correct (yes/no)? " "$newVersion"
read -r confirmation
if [ "$confirmation" != "yes" ]; then
printf "\n"
"$origDir/$0"
exit
fi
printf ">> Updating /web/package.json with \"version\": \"%s\"\n" "$newVersion"
printf "jq \".version = \\\"%s\\\"\" web/package.json > temp.json\n" "$newVersion"
jq ".version = \"${newVersion}\"" web/package.json > temp.json
printf "mv temp.json web/package.json\n"
mv temp.json web/package.json
printf ">> Updating /web/package-lock.json with \"version\": \"%s\"\n" "$newVersion"
printf "jq \".version = \\\"%s\\\"\" web/package-lock.json > temp.json\n" "$newVersion"
jq ".version = \"${newVersion}\"" web/package-lock.json > temp.json
printf "mv temp.json web/package-lock.json\n"
mv temp.json web/package-lock.json
printf ">> Updating /api/src/hff_entry_forms_apipkg/version.nim with PM_API_VERSION* = \"%s\"" "$newVersion"
printf "sed -i \"s/%s/%s/\" api/src/hff_entry_forms_apipkg/version.nim" "$lastVersion" "$newVersion"
sed -i "s/${lastVersion}/${newVersion}/" api/src/hff_entry_forms_apipkg/version.nim
printf ">> Updating /api/hff_entry_forms_api.nimble with version = \"%s\"" "$newVersion"
printf "sed -i \"s/%s/%s/\" api/hff_entry_forms_api.nimble" "$lastVersion" "$newVersion"
sed -i "s/${lastVersion}/${newVersion}/" api/hff_entry_forms_api.nimble
printf ">> Committing new version.\n"
printf "git add web/package.json web/package-lock.json api/src/hff_entry_forms_apipkg/version.nim"
git add web/package.json web/package-lock.json api/src/hff_entry_forms_apipkg/version.nimnim api/hff_entry_forms_api.nimble
printf "git commit -m \"Update package version to %s\"\n" "$newVersion"
git commit -m "Update package version to ${newVersion}"
printf ">> Tagging commit.\n"
printf "git tag -m \"Version %s\" \"%s\"\n" "$newVersion" "$newVersion"
git tag -m "Version ${newVersion}" "${newVersion}"

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/

View File

@ -4,17 +4,17 @@ module.exports = {
node: true,
},
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/typescript/recommended",
"@vue/prettier",
"@vue/prettier/@typescript-eslint",
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/typescript/recommended',
'@vue/prettier',
'@vue/prettier/@typescript-eslint',
],
parserOptions: {
ecmaVersion: 2020,
},
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
};

5
web/Makefile Executable file
View File

@ -0,0 +1,5 @@
build:
npm run build-${TARGET_ENV}
serve:
npm run serve

74
web/package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "hff-entry-form-web",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -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",
@ -1091,6 +1125,11 @@
}
}
},
"@vue/devtools-api": {
"version": "6.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.0.0-beta.19.tgz",
"integrity": "sha512-ObzQhgkoVeoyKv+e8+tB/jQBL2smtk/NmC9OmFK8UqdDpoOdv/Kf9pyDWL+IFyM7qLD2C75rszJujvGSPSpGlw=="
},
"@vue/eslint-config-prettier": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-6.0.0.tgz",
@ -1692,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",
@ -3362,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",
@ -4767,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",
@ -6911,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",
@ -9066,12 +9116,6 @@
"send": "0.17.1"
}
},
"servor": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/servor/-/servor-4.0.2.tgz",
"integrity": "sha512-MlmQ5Ntv4jDYUN060x/KEmN7emvIqKMZ9OkM+nY8Bf2+KkyLmGsTqWLyAN2cZr5oESAcH00UanUyyrlS1LRjFw==",
"dev": true
},
"set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@ -10755,6 +10799,14 @@
}
}
},
"vue-router": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.12.tgz",
"integrity": "sha512-CPXvfqe+mZLB1kBWssssTiWg4EQERyqJZes7USiqfW9B5N2x+nHlnsM1D3b5CaJ6qgCvMmYJnz+G0iWjNCvXrg==",
"requires": {
"@vue/devtools-api": "^6.0.0-beta.18"
}
},
"vue-style-loader": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz",

View File

@ -1,20 +1,26 @@
{
"name": "hff-entry-form-web",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"serve": "npx servor dist",
"vue-serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"build-local": "vue-cli-service build --mode development",
"lint": "vue-cli-service lint",
"vue-serve": "vue-cli-service serve"
},
"dependencies": {
"vue": "^3.0.0"
"@jdbernard/logging": "^1.1.3",
"axios": "^0.23.0",
"dayjs": "^1.10.7",
"vue": "^3.0.0",
"vue-router": "^4.0.0-0"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.18.0",
"@typescript-eslint/parser": "^4.18.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "^4.5.14",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.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,27 +1,4 @@
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<HelloWorld msg="Welcome to Your Vue.js + TypeScript App" />
<router-view />
</template>
<script lang="ts">
import { defineComponent } from "vue";
import HelloWorld from "./components/HelloWorld.vue";
export default defineComponent({
name: "App",
components: {
HelloWorld,
},
});
</script>
<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;
margin-top: 60px;
}
</style>
<style lang="scss" src="./App.scss"></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,4 +1,13 @@
import { createApp } from "vue";
import App from "./App.vue";
import { createApp } from 'vue';
import { logService, LogLevel, ConsoleLogAppender } from '@jdbernard/logging';
createApp(App).mount("#app");
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
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

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

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