Compare commits
10 Commits
0.1.0
...
c3395683db
Author | SHA1 | Date | |
---|---|---|---|
c3395683db | |||
038856e179 | |||
16b291f9c0 | |||
efa6ca215e | |||
1d792f96bb | |||
111dbdce52 | |||
3675c6054a | |||
e055bee0f3 | |||
7c9bdfe8f8 | |||
d70012cb8c |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
.DS_Store
|
||||
.terraform
|
||||
node_modules
|
||||
/web/dist
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
|
32
Makefile
Executable file
32
Makefile
Executable 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
20
api/Dockerfile
Normal 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
81
api/Makefile
Normal 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"
|
7
api/hff_entry_forms_api.config.docker.json
Normal file
7
api/hff_entry_forms_api.config.docker.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"eventParentId": "ffff5ca5cb0547b6a99fb78cdb9b0170",
|
||||
"knownOrigins": [ "https://forms.hopefamilyfellowship.com" ],
|
||||
"notionApiBaseUrl": "https://api.notion.com/v1",
|
||||
"notionVersion": "2021-08-16",
|
||||
"port": 80
|
||||
}
|
@ -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
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Hope Family Fellowship entry forms."
|
||||
license = "GPL-3.0-or-later"
|
||||
|
@ -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]),
|
||||
|
@ -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)
|
||||
|
@ -7,9 +7,15 @@ type
|
||||
purpose*: string
|
||||
department*: string
|
||||
owner*: string
|
||||
location*: string
|
||||
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):
|
||||
@ -36,6 +42,7 @@ proc parseEventProposal*(n: JsonNode): EventProposal {.raises: [JsonParsingError
|
||||
description: n.getOrFail("description").getStr,
|
||||
purpose: n.getOrFail("purpose").getStr,
|
||||
department: n.getOrFail("department").getStr,
|
||||
location: n.getOrFail("location").getStr,
|
||||
owner: n.getOrFail("owner").getStr,
|
||||
date: n.parseIso8601("date"),
|
||||
budgetInDollars: n.getOrFail("budgetInDollars").getInt)
|
||||
@ -48,7 +55,7 @@ proc asNotionPage*(ep: EventProposal): JsonNode =
|
||||
"Event": { "title": textProp(ep.name) },
|
||||
"Date": { "date": { "start": formatIso8601(ep.date) } },
|
||||
"Department": { "multi_select": [ { "name": ep.department } ] },
|
||||
"Location": { "rich_text": textProp("") },
|
||||
"Location": { "rich_text": textProp(ep.location) },
|
||||
"Owner": { "rich_text": textProp(ep.owner) },
|
||||
"State": { "select": { "name": "Proposed" } }
|
||||
},
|
||||
@ -75,3 +82,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 +1 @@
|
||||
const HFF_ENTRY_FORMS_API_VERSION* = "0.1.0"
|
||||
const HFF_ENTRY_FORMS_API_VERSION* = "0.2.1"
|
11
operations/README.md
Normal file
11
operations/README.md
Normal 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.
|
28
operations/invalidate-cdn-cache.sh
Executable file
28
operations/invalidate-cdn-cache.sh
Executable file
@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
echo "Looking up CloudFront distribution ID for HFF Entry Forms ${TARGET_ENV} environment."
|
||||
cloudfront_distribution_id=$(\
|
||||
aws cloudfront list-distributions \
|
||||
--query "DistributionList.Items[?starts_with(Comment, 'HFF Entry Forms ${TARGET_ENV}')].Id | [0]" \
|
||||
| sed -e 's/^"//' -e 's/"$//'
|
||||
)
|
||||
|
||||
if [[ -z "${cloudfront_distribution_id}" ]]; then
|
||||
>&2 echo "Unable to find CloudFront distribution for domain ${TARGET_ENV}."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
echo "Found distribution ID ${cloudfront_distribution_id}."
|
||||
|
||||
echo "Invalidating the CloudFront cache for ${TARGET_ENV}."
|
||||
invalidation_id=$(aws cloudfront create-invalidation \
|
||||
--query 'Invalidation.Id' \
|
||||
--distribution-id "${cloudfront_distribution_id}" \
|
||||
--paths '/index.html')
|
||||
|
||||
if [[ $? -ne 0 || -z "${invalidation_id}" ]]; then
|
||||
>&2 echo "Unable to create the CloudFront invalidation."
|
||||
else
|
||||
echo "Successfully created invalidation ${invalidation_id}."
|
||||
fi
|
||||
|
||||
echo "Done."
|
21
operations/terraform/common.tf
Normal file
21
operations/terraform/common.tf
Normal 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"
|
||||
}
|
99
operations/terraform/deployed_env/cloudfront.tf
Normal file
99
operations/terraform/deployed_env/cloudfront.tf
Normal 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"
|
||||
}
|
||||
}
|
70
operations/terraform/deployed_env/ecs.tf
Normal file
70
operations/terraform/deployed_env/ecs.tf
Normal 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
|
||||
}
|
||||
}
|
69
operations/terraform/deployed_env/iam.tf
Normal file
69
operations/terraform/deployed_env/iam.tf
Normal 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
|
||||
}
|
||||
}
|
43
operations/terraform/deployed_env/load-balancer.tf
Normal file
43
operations/terraform/deployed_env/load-balancer.tf
Normal 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
|
||||
}
|
||||
}
|
42
operations/terraform/deployed_env/variables.tf
Normal file
42
operations/terraform/deployed_env/variables.tf
Normal 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"
|
||||
}
|
||||
}
|
8
operations/terraform/ecr.tf
Normal file
8
operations/terraform/ecr.tf
Normal 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
|
||||
}
|
||||
}
|
38
operations/terraform/main.tf
Normal file
38
operations/terraform/main.tf
Normal 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
|
||||
}
|
8
operations/terraform/terraform.tf
Normal file
8
operations/terraform/terraform.tf
Normal 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"
|
||||
}
|
||||
}
|
0
operations/terraform/terraform.tfstate
Normal file
0
operations/terraform/terraform.tfstate
Normal file
88
operations/terraform/terraform.tfstate.backup
Normal file
88
operations/terraform/terraform.tfstate.backup
Normal 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
63
operations/update-version.sh
Executable 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.nim 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
1
web/.env
Normal file
@ -0,0 +1 @@
|
||||
VUE_APP_API_BASE_URL=https://forms-api-dev.hopefamilyfellowship.com/v1/
|
2
web/.env.development
Normal file
2
web/.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
NODE_ENV=production
|
||||
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/
|
@ -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
5
web/Makefile
Executable file
@ -0,0 +1,5 @@
|
||||
build:
|
||||
npm run build-${TARGET_ENV}
|
||||
|
||||
serve:
|
||||
npm run serve
|
74
web/package-lock.json
generated
74
web/package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hff-entry-form-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"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",
|
||||
|
@ -1,20 +1,27 @@
|
||||
{
|
||||
"name": "hff-entry-form-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "npx servor dist",
|
||||
"vue-serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
"build-dev": "vue-cli-service build --mode development",
|
||||
"build-prod": "vue-cli-service build --mode production",
|
||||
"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",
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 16 KiB |
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,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>
|
||||
|
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,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
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;
|
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
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 / Event Type</span>
|
||||
<select name="department" v-model="formVal.event.department">
|
||||
<option value="">--- select a department or type ---</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>
|
Reference in New Issue
Block a user