belt 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/belt/cli/deploy_command.rb +40 -0
- data/lib/belt/cli/destroy_command.rb +21 -8
- data/lib/belt/cli/environment_command.rb +4 -12
- data/lib/belt/cli/frontend_command.rb +11 -13
- data/lib/belt/cli/frontend_setup_command.rb +14 -25
- data/lib/belt/cli/generate_command.rb +106 -32
- data/lib/belt/cli/new_command.rb +82 -35
- data/lib/belt/cli/routes_command.rb +1 -9
- data/lib/belt/cli/setup_command.rb +2 -8
- data/lib/belt/cli/tables_command.rb +58 -25
- data/lib/belt/cli/views_command.rb +11 -8
- data/lib/belt/inflector.rb +58 -0
- data/lib/belt/lambda_handler.rb +11 -0
- data/lib/belt/route_dsl.rb +3 -11
- data/lib/belt/table_inference.rb +4 -18
- data/lib/belt/version.rb +1 -1
- data/lib/templates/environment/backend.tf.erb +2 -2
- data/lib/templates/environment/main.tf.erb +14 -18
- data/lib/templates/environment/outputs.tf.erb +28 -3
- data/lib/templates/environment/terraform.tfvars.erb +5 -0
- data/lib/templates/environment/variables.tf.erb +6 -0
- data/lib/templates/generate/controller.rb.erb +0 -1
- data/lib/templates/generate/model.rb.erb +0 -2
- data/lib/templates/module/dns.tf.erb +83 -0
- data/lib/templates/module/frontend.tf.erb +179 -0
- data/lib/templates/module/main.tf.erb +42 -0
- data/lib/templates/module/outputs.tf.erb +24 -0
- data/lib/templates/module/variables.tf.erb +27 -0
- data/lib/templates/new_app/lambda/config/environment.rb.erb +7 -1
- data/lib/templates/views/Form.jsx.erb +63 -7
- data/lib/templates/views/Index.jsx.erb +41 -10
- data/lib/templates/views/Show.jsx.erb +12 -2
- metadata +21 -2
- data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +0 -23
|
@@ -32,25 +32,21 @@ provider "conveyor-belt" {
|
|
|
32
32
|
aws_region = var.aws_region
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
source = "${path.module}/../routes.tf.rb"
|
|
38
|
-
app_name = var.app_name
|
|
39
|
-
lambda_source_dir = "${path.module}/../../lambda"
|
|
40
|
-
lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
|
|
41
|
-
frontend_urls = ["http://localhost:3000"]
|
|
42
|
-
friendly_errors = var.environment != "prod"
|
|
35
|
+
module "app" {
|
|
36
|
+
source = "../modules/app"
|
|
43
37
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
app_name = var.app_name
|
|
39
|
+
environment = var.environment
|
|
40
|
+
aws_region = var.aws_region
|
|
41
|
+
domain = var.domain
|
|
42
|
+
frontend_urls = concat(
|
|
43
|
+
var.domain != "" ? ["https://${var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"}"] : [],
|
|
44
|
+
var.environment == "prod" ? [] : ["http://localhost:3000"]
|
|
45
|
+
)
|
|
49
46
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
47
|
+
providers = {
|
|
48
|
+
aws = aws
|
|
49
|
+
random = random
|
|
50
|
+
conveyor-belt = conveyor-belt
|
|
55
51
|
}
|
|
56
52
|
}
|
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
output "api_url" {
|
|
2
2
|
description = "API Gateway base URL"
|
|
3
|
-
value =
|
|
3
|
+
value = module.app.api_url
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
output "api_urls" {
|
|
7
7
|
description = "Map of gateway names to API Gateway URLs"
|
|
8
|
-
value =
|
|
8
|
+
value = module.app.api_urls
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
output "lambda_functions" {
|
|
12
12
|
description = "Map of action names to Lambda function ARNs"
|
|
13
|
-
value =
|
|
13
|
+
value = module.app.lambda_functions
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
output "app_domain" {
|
|
17
|
+
description = "The application domain for this environment"
|
|
18
|
+
value = module.app.app_domain
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
output "name_servers" {
|
|
22
|
+
description = "NS records to configure at your domain registrar (only when domain is set)"
|
|
23
|
+
value = try(module.app.name_servers, [])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
output "frontend_bucket_name" {
|
|
27
|
+
description = "S3 bucket for frontend assets"
|
|
28
|
+
value = try(module.app.frontend_bucket_name, "")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
output "frontend_distribution_id" {
|
|
32
|
+
description = "CloudFront distribution ID"
|
|
33
|
+
value = try(module.app.frontend_distribution_id, "")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
output "frontend_url" {
|
|
37
|
+
description = "Frontend URL"
|
|
38
|
+
value = try(module.app.frontend_url, "")
|
|
14
39
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# DNS configuration for Belt application
|
|
2
|
+
# Convention: prod → mydomain.com, other envs → <env>.mydomain.com
|
|
3
|
+
|
|
4
|
+
locals {
|
|
5
|
+
# Determine the domain for this environment
|
|
6
|
+
app_domain = var.domain != "" ? (
|
|
7
|
+
var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"
|
|
8
|
+
) : ""
|
|
9
|
+
|
|
10
|
+
# API subdomain: api.mydomain.com (prod) or api.<env>.mydomain.com (non-prod)
|
|
11
|
+
api_domain = var.domain != "" ? "api.${local.app_domain}" : ""
|
|
12
|
+
|
|
13
|
+
# Whether DNS is enabled
|
|
14
|
+
dns_enabled = var.domain != ""
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# --- Route53 Hosted Zone ---
|
|
18
|
+
# One zone per environment subdomain (or root domain for prod)
|
|
19
|
+
resource "aws_route53_zone" "app" {
|
|
20
|
+
count = local.dns_enabled ? 1 : 0
|
|
21
|
+
name = local.app_domain
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# --- ACM Certificate ---
|
|
25
|
+
# Wildcard cert covers the app domain + all subdomains (api.*, www.*, etc.)
|
|
26
|
+
resource "aws_acm_certificate" "app" {
|
|
27
|
+
count = local.dns_enabled ? 1 : 0
|
|
28
|
+
domain_name = local.app_domain
|
|
29
|
+
subject_alternative_names = ["*.${local.app_domain}"]
|
|
30
|
+
validation_method = "DNS"
|
|
31
|
+
|
|
32
|
+
lifecycle {
|
|
33
|
+
create_before_destroy = true
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# DNS validation record for the certificate
|
|
38
|
+
resource "aws_route53_record" "cert_validation" {
|
|
39
|
+
for_each = local.dns_enabled ? {
|
|
40
|
+
for dvo in aws_acm_certificate.app[0].domain_validation_options : dvo.domain_name => {
|
|
41
|
+
name = dvo.resource_record_name
|
|
42
|
+
type = dvo.resource_record_type
|
|
43
|
+
record = dvo.resource_record_value
|
|
44
|
+
}
|
|
45
|
+
} : {}
|
|
46
|
+
|
|
47
|
+
zone_id = aws_route53_zone.app[0].zone_id
|
|
48
|
+
name = each.value.name
|
|
49
|
+
type = each.value.type
|
|
50
|
+
ttl = 300
|
|
51
|
+
records = [each.value.record]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resource "aws_acm_certificate_validation" "app" {
|
|
55
|
+
count = local.dns_enabled ? 1 : 0
|
|
56
|
+
certificate_arn = aws_acm_certificate.app[0].arn
|
|
57
|
+
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# --- API Gateway Custom Domain ---
|
|
61
|
+
resource "aws_api_gateway_domain_name" "api" {
|
|
62
|
+
count = local.dns_enabled ? 1 : 0
|
|
63
|
+
domain_name = local.api_domain
|
|
64
|
+
regional_certificate_arn = aws_acm_certificate_validation.app[0].certificate_arn
|
|
65
|
+
|
|
66
|
+
endpoint_configuration {
|
|
67
|
+
types = ["REGIONAL"]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Route53 A record for api.<domain>
|
|
72
|
+
resource "aws_route53_record" "api" {
|
|
73
|
+
count = local.dns_enabled ? 1 : 0
|
|
74
|
+
zone_id = aws_route53_zone.app[0].zone_id
|
|
75
|
+
name = local.api_domain
|
|
76
|
+
type = "A"
|
|
77
|
+
|
|
78
|
+
alias {
|
|
79
|
+
name = aws_api_gateway_domain_name.api[0].regional_domain_name
|
|
80
|
+
zone_id = aws_api_gateway_domain_name.api[0].regional_zone_id
|
|
81
|
+
evaluate_target_health = false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Frontend hosting — S3 + CloudFront
|
|
2
|
+
# Custom domain attached when var.domain is set
|
|
3
|
+
|
|
4
|
+
resource "random_string" "frontend_suffix" {
|
|
5
|
+
length = 8
|
|
6
|
+
special = false
|
|
7
|
+
upper = false
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
# S3 bucket for frontend static assets
|
|
11
|
+
resource "aws_s3_bucket" "frontend" {
|
|
12
|
+
bucket = "${var.app_name}-frontend-${var.environment}-${random_string.frontend_suffix.result}"
|
|
13
|
+
|
|
14
|
+
lifecycle {
|
|
15
|
+
ignore_changes = [bucket]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
resource "aws_s3_bucket_public_access_block" "frontend" {
|
|
20
|
+
bucket = aws_s3_bucket.frontend.id
|
|
21
|
+
|
|
22
|
+
block_public_acls = true
|
|
23
|
+
block_public_policy = true
|
|
24
|
+
ignore_public_acls = true
|
|
25
|
+
restrict_public_buckets = true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
resource "aws_s3_bucket_server_side_encryption_configuration" "frontend" {
|
|
29
|
+
bucket = aws_s3_bucket.frontend.id
|
|
30
|
+
|
|
31
|
+
rule {
|
|
32
|
+
apply_server_side_encryption_by_default {
|
|
33
|
+
sse_algorithm = "AES256"
|
|
34
|
+
}
|
|
35
|
+
bucket_key_enabled = true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# CloudFront OAC
|
|
40
|
+
resource "aws_cloudfront_origin_access_control" "frontend" {
|
|
41
|
+
name = "${var.app_name}-${var.environment}-frontend-oac"
|
|
42
|
+
description = "OAC for frontend bucket"
|
|
43
|
+
origin_access_control_origin_type = "s3"
|
|
44
|
+
signing_behavior = "always"
|
|
45
|
+
signing_protocol = "sigv4"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# CloudFront distribution
|
|
49
|
+
resource "aws_cloudfront_distribution" "frontend" {
|
|
50
|
+
origin {
|
|
51
|
+
domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
|
|
52
|
+
origin_id = "S3-${aws_s3_bucket.frontend.id}"
|
|
53
|
+
origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
enabled = true
|
|
57
|
+
is_ipv6_enabled = true
|
|
58
|
+
default_root_object = "index.html"
|
|
59
|
+
|
|
60
|
+
# Custom domain aliases (if DNS configured)
|
|
61
|
+
aliases = local.dns_enabled ? compact([
|
|
62
|
+
local.app_domain,
|
|
63
|
+
var.environment == "prod" ? "www.${var.domain}" : ""
|
|
64
|
+
]) : []
|
|
65
|
+
|
|
66
|
+
default_cache_behavior {
|
|
67
|
+
allowed_methods = ["GET", "HEAD", "OPTIONS"]
|
|
68
|
+
cached_methods = ["GET", "HEAD"]
|
|
69
|
+
target_origin_id = "S3-${aws_s3_bucket.frontend.id}"
|
|
70
|
+
compress = true
|
|
71
|
+
viewer_protocol_policy = "redirect-to-https"
|
|
72
|
+
|
|
73
|
+
forwarded_values {
|
|
74
|
+
query_string = false
|
|
75
|
+
cookies {
|
|
76
|
+
forward = "none"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# SPA routing — serve index.html for 404/403
|
|
82
|
+
custom_error_response {
|
|
83
|
+
error_code = 404
|
|
84
|
+
response_code = 200
|
|
85
|
+
response_page_path = "/index.html"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
custom_error_response {
|
|
89
|
+
error_code = 403
|
|
90
|
+
response_code = 200
|
|
91
|
+
response_page_path = "/index.html"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
restrictions {
|
|
95
|
+
geo_restriction {
|
|
96
|
+
restriction_type = "none"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
viewer_certificate {
|
|
101
|
+
cloudfront_default_certificate = !local.dns_enabled
|
|
102
|
+
acm_certificate_arn = local.dns_enabled ? aws_acm_certificate_validation.app[0].certificate_arn : null
|
|
103
|
+
ssl_support_method = local.dns_enabled ? "sni-only" : null
|
|
104
|
+
minimum_protocol_version = local.dns_enabled ? "TLSv1.2_2021" : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
tags = {
|
|
108
|
+
Name = "${var.app_name}-${var.environment}-frontend"
|
|
109
|
+
Environment = var.environment
|
|
110
|
+
ManagedBy = "Terraform"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# S3 bucket policy — CloudFront OAC access only
|
|
115
|
+
resource "aws_s3_bucket_policy" "frontend" {
|
|
116
|
+
bucket = aws_s3_bucket.frontend.id
|
|
117
|
+
|
|
118
|
+
policy = jsonencode({
|
|
119
|
+
Version = "2012-10-17"
|
|
120
|
+
Statement = [
|
|
121
|
+
{
|
|
122
|
+
Sid = "AllowCloudFrontServicePrincipal"
|
|
123
|
+
Effect = "Allow"
|
|
124
|
+
Principal = {
|
|
125
|
+
Service = "cloudfront.amazonaws.com"
|
|
126
|
+
}
|
|
127
|
+
Action = "s3:GetObject"
|
|
128
|
+
Resource = "${aws_s3_bucket.frontend.arn}/*"
|
|
129
|
+
Condition = {
|
|
130
|
+
StringEquals = {
|
|
131
|
+
"AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
depends_on = [aws_s3_bucket_public_access_block.frontend]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Route53 record for frontend (app domain → CloudFront)
|
|
142
|
+
resource "aws_route53_record" "frontend" {
|
|
143
|
+
count = local.dns_enabled ? 1 : 0
|
|
144
|
+
zone_id = aws_route53_zone.app[0].zone_id
|
|
145
|
+
name = local.app_domain
|
|
146
|
+
type = "A"
|
|
147
|
+
|
|
148
|
+
alias {
|
|
149
|
+
name = aws_cloudfront_distribution.frontend.domain_name
|
|
150
|
+
zone_id = aws_cloudfront_distribution.frontend.hosted_zone_id
|
|
151
|
+
evaluate_target_health = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# www record for prod only
|
|
156
|
+
resource "aws_route53_record" "frontend_www" {
|
|
157
|
+
count = local.dns_enabled && var.environment == "prod" ? 1 : 0
|
|
158
|
+
zone_id = aws_route53_zone.app[0].zone_id
|
|
159
|
+
name = "www.${var.domain}"
|
|
160
|
+
type = "A"
|
|
161
|
+
|
|
162
|
+
alias {
|
|
163
|
+
name = aws_cloudfront_distribution.frontend.domain_name
|
|
164
|
+
zone_id = aws_cloudfront_distribution.frontend.hosted_zone_id
|
|
165
|
+
evaluate_target_health = false
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
output "frontend_bucket_name" {
|
|
170
|
+
value = aws_s3_bucket.frontend.id
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
output "frontend_distribution_id" {
|
|
174
|
+
value = aws_cloudfront_distribution.frontend.id
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
output "frontend_url" {
|
|
178
|
+
value = local.dns_enabled ? "https://${local.app_domain}" : "https://${aws_cloudfront_distribution.frontend.domain_name}"
|
|
179
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Belt application module — defines all shared infrastructure.
|
|
2
|
+
# Environments call this module with different variables.
|
|
3
|
+
|
|
4
|
+
terraform {
|
|
5
|
+
required_providers {
|
|
6
|
+
aws = {
|
|
7
|
+
source = "hashicorp/aws"
|
|
8
|
+
version = "~> 5.0"
|
|
9
|
+
}
|
|
10
|
+
random = {
|
|
11
|
+
source = "hashicorp/random"
|
|
12
|
+
version = "~> 3.0"
|
|
13
|
+
}
|
|
14
|
+
conveyor-belt = {
|
|
15
|
+
source = "stowzilla/conveyor-belt"
|
|
16
|
+
version = "~> 0.0.1"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
resource "conveyor_belt" "main" {
|
|
22
|
+
provider = conveyor-belt
|
|
23
|
+
|
|
24
|
+
source = "${path.module}/../../routes.tf.rb"
|
|
25
|
+
app_name = var.app_name
|
|
26
|
+
lambda_source_dir = "${path.module}/../../../lambda"
|
|
27
|
+
lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
|
|
28
|
+
frontend_urls = var.frontend_urls
|
|
29
|
+
friendly_errors = var.environment != "prod"
|
|
30
|
+
|
|
31
|
+
custom_domain_name = local.dns_enabled ? local.api_domain : ""
|
|
32
|
+
|
|
33
|
+
# Per-lambda configuration: env vars, timeouts, memory
|
|
34
|
+
lambda_config = {
|
|
35
|
+
api = {
|
|
36
|
+
env_vars = {
|
|
37
|
+
WELCOME_TITLE = "Welcome to ${var.app_name}"
|
|
38
|
+
WELCOME_SUBTITLE = "API Gateway → Lambda → DynamoDB — all connected."
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
output "api_url" {
|
|
2
|
+
description = "API Gateway base URL (custom domain if configured, otherwise default)"
|
|
3
|
+
value = var.domain != "" ? "https://${local.api_domain}" : values(conveyor_belt.main.api_gateway_urls)[0]
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
output "api_urls" {
|
|
7
|
+
description = "Map of gateway names to API Gateway URLs"
|
|
8
|
+
value = conveyor_belt.main.api_gateway_urls
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
output "lambda_functions" {
|
|
12
|
+
description = "Map of action names to Lambda function ARNs"
|
|
13
|
+
value = conveyor_belt.main.lambda_functions
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
output "app_domain" {
|
|
17
|
+
description = "The application domain for this environment"
|
|
18
|
+
value = local.app_domain
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
output "name_servers" {
|
|
22
|
+
description = "NS records to configure at your registrar"
|
|
23
|
+
value = local.dns_enabled ? aws_route53_zone.app[0].name_servers : []
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
variable "app_name" {
|
|
2
|
+
description = "Name of the application"
|
|
3
|
+
type = string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
variable "environment" {
|
|
7
|
+
description = "Environment name (e.g., dev, prod, staging)"
|
|
8
|
+
type = string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
variable "aws_region" {
|
|
12
|
+
description = "AWS region"
|
|
13
|
+
type = string
|
|
14
|
+
default = "us-east-1"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
variable "domain" {
|
|
18
|
+
description = "Root domain for the application (e.g., myapp.com). Leave empty to skip DNS."
|
|
19
|
+
type = string
|
|
20
|
+
default = ""
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
variable "frontend_urls" {
|
|
24
|
+
description = "URLs allowed for CORS frontend access"
|
|
25
|
+
type = list(string)
|
|
26
|
+
default = []
|
|
27
|
+
}
|
|
@@ -15,4 +15,10 @@ end
|
|
|
15
15
|
|
|
16
16
|
# Load lib and models
|
|
17
17
|
Dir[File.join(__dir__, '..', 'lib', '**', '*.rb')].sort.each { |f| require f }
|
|
18
|
-
|
|
18
|
+
|
|
19
|
+
models_dir = File.join(__dir__, '..', 'models')
|
|
20
|
+
all_models = Dir[File.join(models_dir, '**', '*.rb')].sort
|
|
21
|
+
app_record = all_models.find { |f| File.basename(f) == 'application_record.rb' }
|
|
22
|
+
rest = all_models - [app_record].compact
|
|
23
|
+
require app_record if app_record
|
|
24
|
+
rest.each { |f| require f }
|
|
@@ -3,7 +3,13 @@ import { Link } from 'react-router-dom'
|
|
|
3
3
|
|
|
4
4
|
export default function <%= @class_name %>Form({ initialValues = {}, onSubmit, submitLabel = 'Create' }) {
|
|
5
5
|
<% @fields.each do |field| -%>
|
|
6
|
+
<% if field[:type] == 'boolean' -%>
|
|
7
|
+
const [<%= field[:name] %>, set<%= field[:name].split('_').map(&:capitalize).join %>] = useState(initialValues.<%= field[:name] %> || false)
|
|
8
|
+
<% elsif field[:type] == 'integer' || field[:type] == 'float' -%>
|
|
9
|
+
const [<%= field[:name] %>, set<%= field[:name].split('_').map(&:capitalize).join %>] = useState(initialValues.<%= field[:name] %> ?? '')
|
|
10
|
+
<% else -%>
|
|
6
11
|
const [<%= field[:name] %>, set<%= field[:name].split('_').map(&:capitalize).join %>] = useState(initialValues.<%= field[:name] %> || '')
|
|
12
|
+
<% end -%>
|
|
7
13
|
<% end -%>
|
|
8
14
|
|
|
9
15
|
function handleSubmit(e) {
|
|
@@ -15,20 +21,70 @@ export default function <%= @class_name %>Form({ initialValues = {}, onSubmit, s
|
|
|
15
21
|
<form onSubmit={handleSubmit}>
|
|
16
22
|
<% @fields.each do |field| -%>
|
|
17
23
|
<div style={{ marginBottom: '1rem' }}>
|
|
18
|
-
<label style={{ display: 'block', marginBottom: '0.25rem' }}><%= field[:name].split('_').map(&:capitalize).join(' ') %></label>
|
|
19
|
-
<%
|
|
24
|
+
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 500 }}><%= field[:name].split('_').map(&:capitalize).join(' ') %></label>
|
|
25
|
+
<% case field[:type] -%>
|
|
26
|
+
<% when 'text' -%>
|
|
20
27
|
<textarea value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
|
|
21
|
-
rows={6} style={{ width: '100%' }} />
|
|
28
|
+
rows={6} style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
|
|
29
|
+
<% when 'boolean' -%>
|
|
30
|
+
<label style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer', gap: '0.5rem' }}>
|
|
31
|
+
<span style={{
|
|
32
|
+
position: 'relative',
|
|
33
|
+
display: 'inline-block',
|
|
34
|
+
width: '44px',
|
|
35
|
+
height: '24px',
|
|
36
|
+
borderRadius: '12px',
|
|
37
|
+
backgroundColor: <%= field[:name] %> ? '#4f46e5' : '#d1d5db',
|
|
38
|
+
transition: 'background-color 0.2s'
|
|
39
|
+
}}>
|
|
40
|
+
<span style={{
|
|
41
|
+
position: 'absolute',
|
|
42
|
+
top: '2px',
|
|
43
|
+
left: <%= field[:name] %> ? '22px' : '2px',
|
|
44
|
+
width: '20px',
|
|
45
|
+
height: '20px',
|
|
46
|
+
borderRadius: '50%',
|
|
47
|
+
backgroundColor: '#fff',
|
|
48
|
+
transition: 'left 0.2s',
|
|
49
|
+
boxShadow: '0 1px 3px rgba(0,0,0,0.2)'
|
|
50
|
+
}} />
|
|
51
|
+
</span>
|
|
52
|
+
<input type="checkbox" checked={<%= field[:name] %>}
|
|
53
|
+
onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.checked)}
|
|
54
|
+
style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }} />
|
|
55
|
+
<span style={{ fontSize: '0.875rem', color: '#6b7280' }}>{<%= field[:name] %> ? 'Yes' : 'No'}</span>
|
|
56
|
+
</label>
|
|
57
|
+
<% when 'date' -%>
|
|
58
|
+
<input type="date" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
|
|
59
|
+
style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
|
|
60
|
+
<% when 'datetime' -%>
|
|
61
|
+
<input type="datetime-local" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
|
|
62
|
+
style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
|
|
63
|
+
<% when 'integer' -%>
|
|
64
|
+
<input type="number" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
|
|
65
|
+
step="1" style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
|
|
66
|
+
<% when 'float' -%>
|
|
67
|
+
<input type="number" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
|
|
68
|
+
step="any" style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
|
|
22
69
|
<% else -%>
|
|
23
70
|
<input type="text" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
|
|
24
|
-
style={{ width: '100%', padding: '0.5rem' }} />
|
|
71
|
+
style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
|
|
25
72
|
<% end -%>
|
|
26
73
|
</div>
|
|
27
74
|
<% end -%>
|
|
28
75
|
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
76
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
|
|
77
|
+
<button type="submit" style={{
|
|
78
|
+
padding: '0.5rem 1.5rem',
|
|
79
|
+
backgroundColor: '#4f46e5',
|
|
80
|
+
color: '#fff',
|
|
81
|
+
border: 'none',
|
|
82
|
+
borderRadius: '0.375rem',
|
|
83
|
+
cursor: 'pointer',
|
|
84
|
+
fontWeight: 500
|
|
85
|
+
}}>{submitLabel}</button>
|
|
86
|
+
<Link to="/<%= @resource_name %>" style={{ color: '#4f46e5' }}>Cancel</Link>
|
|
87
|
+
</div>
|
|
32
88
|
</form>
|
|
33
89
|
)
|
|
34
90
|
}
|
|
@@ -18,21 +18,52 @@ export default function <%= @class_name %>sIndex() {
|
|
|
18
18
|
<main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
|
19
19
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
20
20
|
<h1><%= @class_name %>s</h1>
|
|
21
|
-
<Link to="/<%= @resource_name %>/new"
|
|
21
|
+
<Link to="/<%= @resource_name %>/new" style={{
|
|
22
|
+
padding: '0.5rem 1rem',
|
|
23
|
+
backgroundColor: '#4f46e5',
|
|
24
|
+
color: '#fff',
|
|
25
|
+
borderRadius: '0.375rem',
|
|
26
|
+
textDecoration: 'none',
|
|
27
|
+
fontWeight: 500
|
|
28
|
+
}}>New <%= @class_name %></Link>
|
|
22
29
|
</div>
|
|
23
30
|
|
|
24
31
|
{<%= @resource_name %>.length === 0 ? (
|
|
25
32
|
<p>No <%= @resource_name %> yet.</p>
|
|
26
33
|
) : (
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '1rem' }}>
|
|
35
|
+
<thead>
|
|
36
|
+
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
|
37
|
+
<% @fields.each do |field| -%>
|
|
38
|
+
<th style={{ padding: '0.5rem' }}><%= field[:name].split('_').map(&:capitalize).join(' ') %></th>
|
|
39
|
+
<% end -%>
|
|
40
|
+
<th style={{ padding: '0.5rem' }}></th>
|
|
41
|
+
</tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody>
|
|
44
|
+
{<%= @resource_name %>.map(<%= @singular_name %> => (
|
|
45
|
+
<tr key={<%= @singular_name %>.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
|
46
|
+
<% @fields.each do |field| -%>
|
|
47
|
+
<% case field[:type] -%>
|
|
48
|
+
<% when 'boolean' -%>
|
|
49
|
+
<td style={{ padding: '0.5rem' }}>{<%= @singular_name %>.<%= field[:name] %> ? '✓' : '✗'}</td>
|
|
50
|
+
<% when 'date' -%>
|
|
51
|
+
<td style={{ padding: '0.5rem' }}>{<%= @singular_name %>.<%= field[:name] %> ? new Date(<%= @singular_name %>.<%= field[:name] %>).toLocaleDateString() : '—'}</td>
|
|
52
|
+
<% when 'datetime' -%>
|
|
53
|
+
<td style={{ padding: '0.5rem' }}>{<%= @singular_name %>.<%= field[:name] %> ? new Date(<%= @singular_name %>.<%= field[:name] %>).toLocaleString() : '—'}</td>
|
|
54
|
+
<% when 'text' -%>
|
|
55
|
+
<td style={{ padding: '0.5rem', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{<%= @singular_name %>.<%= field[:name] %>}</td>
|
|
56
|
+
<% else -%>
|
|
57
|
+
<td style={{ padding: '0.5rem' }}>{<%= @singular_name %>.<%= field[:name] %>}</td>
|
|
58
|
+
<% end -%>
|
|
59
|
+
<% end -%>
|
|
60
|
+
<td style={{ padding: '0.5rem' }}>
|
|
61
|
+
<Link to={`/<%= @resource_name %>/${<%= @singular_name %>.id}`}>Show</Link>
|
|
62
|
+
</td>
|
|
63
|
+
</tr>
|
|
64
|
+
))}
|
|
65
|
+
</tbody>
|
|
66
|
+
</table>
|
|
36
67
|
)}
|
|
37
68
|
</main>
|
|
38
69
|
)
|