belt 0.0.6 → 0.1.0

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +4 -0
  4. data/exe/belt +6 -0
  5. data/lib/belt/action_router.rb +7 -1
  6. data/lib/belt/cli/app_detection.rb +16 -0
  7. data/lib/belt/cli/bucket_security.rb +122 -0
  8. data/lib/belt/cli/env_resolver.rb +15 -0
  9. data/lib/belt/cli/environment_command.rb +77 -0
  10. data/lib/belt/cli/frontend_command.rb +85 -0
  11. data/lib/belt/cli/frontend_deploy_command.rb +125 -0
  12. data/lib/belt/cli/frontend_setup_command.rb +64 -0
  13. data/lib/belt/cli/generate_command.rb +206 -0
  14. data/lib/belt/cli/new_command.rb +125 -0
  15. data/lib/belt/cli/setup_command.rb +261 -0
  16. data/lib/belt/cli/tables_command.rb +138 -0
  17. data/lib/belt/cli/terraform_command.rb +77 -0
  18. data/lib/belt/cli/views_command.rb +134 -0
  19. data/lib/belt/cli.rb +98 -0
  20. data/lib/belt/lambda_handler.rb +16 -0
  21. data/lib/belt/version.rb +1 -1
  22. data/lib/belt.rb +1 -1
  23. data/lib/templates/environment/backend.tf.erb +8 -0
  24. data/lib/templates/environment/main.tf.erb +42 -0
  25. data/lib/templates/environment/terraform.tfvars.erb +1 -0
  26. data/lib/templates/environment/variables.tf.erb +16 -0
  27. data/lib/templates/frontend/react/index.html.erb +12 -0
  28. data/lib/templates/frontend/react/package.json.erb +20 -0
  29. data/lib/templates/frontend/react/src/App.jsx +14 -0
  30. data/lib/templates/frontend/react/src/index.css +10 -0
  31. data/lib/templates/frontend/react/src/lib/apiClient.js.erb +19 -0
  32. data/lib/templates/frontend/react/src/main.jsx +10 -0
  33. data/lib/templates/frontend/react/src/pages/Home.jsx.erb +10 -0
  34. data/lib/templates/frontend/react/vite.config.js +8 -0
  35. data/lib/templates/frontend_infra/frontend.tf.erb +159 -0
  36. data/lib/templates/generate/controller.rb.erb +59 -0
  37. data/lib/templates/generate/model.rb.erb +20 -0
  38. data/lib/templates/new_app/AGENTS.md.erb +130 -0
  39. data/lib/templates/new_app/Gemfile.erb +6 -0
  40. data/lib/templates/new_app/README.md.erb +25 -0
  41. data/lib/templates/new_app/gitignore.erb +14 -0
  42. data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +5 -0
  43. data/lib/templates/new_app/infrastructure/schema.tf.rb.erb +9 -0
  44. data/lib/templates/new_app/lambda/Gemfile.erb +7 -0
  45. data/lib/templates/new_app/lambda/api.rb.erb +22 -0
  46. data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +6 -0
  47. data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +11 -0
  48. data/lib/templates/new_app/lambda/models/application_record.rb.erb +6 -0
  49. data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +23 -0
  50. data/lib/templates/views/Edit.jsx.erb +38 -0
  51. data/lib/templates/views/Form.jsx.erb +34 -0
  52. data/lib/templates/views/Index.jsx.erb +39 -0
  53. data/lib/templates/views/New.jsx.erb +26 -0
  54. data/lib/templates/views/Show.jsx.erb +46 -0
  55. data.tar.gz.sig +0 -0
  56. metadata +51 -3
  57. metadata.gz.sig +0 -0
@@ -0,0 +1,159 @@
1
+ # Auto-generated by Belt — frontend hosting infrastructure
2
+ # Re-run `belt setup frontend <%= @env %>` to regenerate
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 = "<%= @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
+ resource "aws_s3_bucket_website_configuration" "frontend" {
40
+ bucket = aws_s3_bucket.frontend.id
41
+
42
+ index_document {
43
+ suffix = "index.html"
44
+ }
45
+
46
+ error_document {
47
+ key = "index.html"
48
+ }
49
+ }
50
+
51
+ # CloudFront OAC — restricts S3 access to CloudFront only
52
+ resource "aws_cloudfront_origin_access_control" "frontend" {
53
+ name = "<%= @app_name %>-${var.environment}-frontend-oac"
54
+ description = "OAC for frontend bucket"
55
+ origin_access_control_origin_type = "s3"
56
+ signing_behavior = "always"
57
+ signing_protocol = "sigv4"
58
+ }
59
+
60
+ # CloudFront distribution
61
+ resource "aws_cloudfront_distribution" "frontend" {
62
+ origin {
63
+ domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
64
+ origin_id = "S3-${aws_s3_bucket.frontend.id}"
65
+ origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
66
+ }
67
+
68
+ enabled = true
69
+ is_ipv6_enabled = true
70
+ default_root_object = "index.html"
71
+
72
+ default_cache_behavior {
73
+ allowed_methods = ["GET", "HEAD", "OPTIONS"]
74
+ cached_methods = ["GET", "HEAD"]
75
+ target_origin_id = "S3-${aws_s3_bucket.frontend.id}"
76
+ compress = true
77
+ viewer_protocol_policy = "redirect-to-https"
78
+
79
+ forwarded_values {
80
+ query_string = false
81
+ cookies {
82
+ forward = "none"
83
+ }
84
+ }
85
+ }
86
+
87
+ # SPA routing — serve index.html for all 404/403 paths
88
+ custom_error_response {
89
+ error_code = 404
90
+ response_code = 200
91
+ response_page_path = "/index.html"
92
+ }
93
+
94
+ custom_error_response {
95
+ error_code = 403
96
+ response_code = 200
97
+ response_page_path = "/index.html"
98
+ }
99
+
100
+ restrictions {
101
+ geo_restriction {
102
+ restriction_type = "none"
103
+ }
104
+ }
105
+
106
+ viewer_certificate {
107
+ cloudfront_default_certificate = true
108
+ }
109
+
110
+ tags = {
111
+ Name = "<%= @app_name %>-${var.environment}-frontend"
112
+ Environment = var.environment
113
+ ManagedBy = "Terraform"
114
+ }
115
+ }
116
+
117
+ # S3 bucket policy — allow CloudFront OAC access only
118
+ resource "aws_s3_bucket_policy" "frontend" {
119
+ bucket = aws_s3_bucket.frontend.id
120
+
121
+ policy = jsonencode({
122
+ Version = "2012-10-17"
123
+ Statement = [
124
+ {
125
+ Sid = "AllowCloudFrontServicePrincipal"
126
+ Effect = "Allow"
127
+ Principal = {
128
+ Service = "cloudfront.amazonaws.com"
129
+ }
130
+ Action = "s3:GetObject"
131
+ Resource = "${aws_s3_bucket.frontend.arn}/*"
132
+ Condition = {
133
+ StringEquals = {
134
+ "AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
135
+ }
136
+ }
137
+ }
138
+ ]
139
+ })
140
+
141
+ depends_on = [aws_s3_bucket_public_access_block.frontend]
142
+ }
143
+
144
+ # Outputs for belt deploy
145
+ output "frontend_bucket_name" {
146
+ value = aws_s3_bucket.frontend.id
147
+ }
148
+
149
+ output "frontend_distribution_id" {
150
+ value = aws_cloudfront_distribution.frontend.id
151
+ }
152
+
153
+ output "frontend_url" {
154
+ value = "https://${aws_cloudfront_distribution.frontend.domain_name}"
155
+ }
156
+
157
+ output "api_url" {
158
+ value = values(dispatcher.main.api_gateway_urls)[0]
159
+ }
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'application_controller'
4
+ require_relative '../../models/<%= @singular_name %>'
5
+
6
+ module <%= @module_name %>Controllers
7
+ class <%= @class_name %>sController < ApplicationController
8
+ # GET /<%= @resource_name %>
9
+ def index
10
+ <%= @resource_name %> = <%= @class_name %>.all
11
+ success_response(<%= @resource_name %>: <%= @resource_name %>.map(&:to_h))
12
+ end
13
+
14
+ # POST /<%= @resource_name %>
15
+ def create
16
+ <%= @singular_name %> = <%= @class_name %>.new(<%= @fields.map { |f| "#{f[:name]}: params[:#{f[:name]}]" }.join(', ') %>)
17
+
18
+ if <%= @singular_name %>.save
19
+ success_response(<%= @singular_name %>: <%= @singular_name %>.to_h)
20
+ else
21
+ error_response(<%= @singular_name %>.errors.full_messages.join(', '), 422)
22
+ end
23
+ end
24
+
25
+ # GET /<%= @resource_name %>/:<%= @singular_name %>_id
26
+ def show
27
+ <%= @singular_name %> = <%= @class_name %>.find(params[:<%= @singular_name %>_id])
28
+ return error_response('<%= @class_name %> not found', 404) unless <%= @singular_name %>
29
+
30
+ success_response(<%= @singular_name %>: <%= @singular_name %>.to_h)
31
+ end
32
+
33
+ # PUT /<%= @resource_name %>/:<%= @singular_name %>_id
34
+ def update
35
+ <%= @singular_name %> = <%= @class_name %>.find(params[:<%= @singular_name %>_id])
36
+ return error_response('<%= @class_name %> not found', 404) unless <%= @singular_name %>
37
+
38
+ attrs = {}
39
+ <% @fields.each do |field| -%>
40
+ attrs[:<%= field[:name] %>] = params[:<%= field[:name] %>] if params.key?(:<%= field[:name] %>)
41
+ <% end -%>
42
+
43
+ if <%= @singular_name %>.update(attrs)
44
+ success_response(<%= @singular_name %>: <%= @singular_name %>.to_h)
45
+ else
46
+ error_response(<%= @singular_name %>.errors.full_messages.join(', '), 422)
47
+ end
48
+ end
49
+
50
+ # DELETE /<%= @resource_name %>/:<%= @singular_name %>_id
51
+ def destroy
52
+ <%= @singular_name %> = <%= @class_name %>.find(params[:<%= @singular_name %>_id])
53
+ return error_response('<%= @class_name %> not found', 404) unless <%= @singular_name %>
54
+
55
+ <%= @singular_name %>.destroy
56
+ success_response(message: '<%= @class_name %> deleted')
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @class_name %> < ApplicationRecord
4
+ include Concerns::Timestampable
5
+
6
+ <% @fields.each do |field| -%>
7
+ attr_accessor :<%= field[:name] %>
8
+ <% end -%>
9
+
10
+ def to_h
11
+ {
12
+ id: id,
13
+ <% @fields.each do |field| -%>
14
+ <%= field[:name] %>: <%= field[:name] %>,
15
+ <% end -%>
16
+ created_at: created_at,
17
+ updated_at: updated_at
18
+ }
19
+ end
20
+ end
@@ -0,0 +1,130 @@
1
+ # AGENTS.md — <%= @module_name %>
2
+
3
+ This file explains the project structure, tooling, and conventions for AI agents working in this codebase.
4
+
5
+ ## Stack
6
+
7
+ - **Belt** — CLI and runtime framework (like Rails for serverless). Provides Lambda handler, action router, controller base class, and CLI tooling.
8
+ - **ActiveItem** — ActiveRecord-like ORM for DynamoDB. Models inherit from `ActiveItem::Base`.
9
+ - **Dispatcher** — Terraform provider that reads a Ruby DSL (`routes.tf.rb`) and creates API Gateway + Lambda + IAM infrastructure.
10
+ - **Lambda Loadout** — Lambda cold-start optimizer (auto-required by Belt).
11
+
12
+ ## Project Structure
13
+
14
+ ```
15
+ <%= @app_name %>/
16
+ ├── lambda/
17
+ │ ├── <%= @app_name %>.rb # Lambda entry point
18
+ │ ├── controllers/<%= @app_name %>/ # Controllers (namespaced)
19
+ │ │ └── application_controller.rb # Base controller
20
+ │ ├── models/ # ActiveItem models
21
+ │ │ ├── application_record.rb # Base model
22
+ │ │ └── concerns/ # Shared model concerns
23
+ │ ├── lib/routes/ # Route manifests (auto-generated)
24
+ │ └── Gemfile # Lambda-specific dependencies
25
+ ├── infrastructure/
26
+ │ ├── routes.tf.rb # API routes (Dispatcher DSL)
27
+ │ ├── schema.tf.rb # Model schema definitions
28
+ │ └── <env>/ # Per-environment Terraform configs
29
+ ├── Gemfile # Project-level dependencies
30
+ └── AGENTS.md # This file
31
+ ```
32
+
33
+ ## Belt CLI Commands
34
+
35
+ ```bash
36
+ belt new <app_name> # Scaffold a new project
37
+ belt generate resource <name> <field:type ...> # Generate model + controller + routes
38
+ belt generate model <name> <field:type ...> # Generate model only
39
+ belt generate controller <name> # Generate controller only
40
+ belt generate environment <env_name> # Generate Terraform environment directory
41
+ belt setup state # Create/select S3 state bucket
42
+ belt setup tables <env> # Generate DynamoDB table definitions from schema
43
+ belt init <env> # terraform init
44
+ belt plan <env> # terraform plan
45
+ belt apply <env> # terraform apply
46
+ belt destroy <env> # terraform destroy
47
+ belt output <env> # terraform output
48
+ ```
49
+
50
+ ## How Routing Works
51
+
52
+ 1. `infrastructure/routes.tf.rb` defines routes using a DSL:
53
+ ```ruby
54
+ TerraDispatch.routes.draw do
55
+ namespace :<%= @app_name %> do
56
+ resources :things, tables: [:things]
57
+ end
58
+ end
59
+ ```
60
+
61
+ 2. Dispatcher creates an API Gateway where the namespace becomes a base path mapping. URLs look like:
62
+ ```
63
+ https://api.<env>.example.com/<%= @app_name %>/things
64
+ ```
65
+
66
+ 3. The Lambda entry point (`lambda/<%= @app_name %>.rb`) uses `Belt::ActionRouter` to dispatch requests to controllers based on the route manifest in `lambda/lib/routes/`.
67
+
68
+ 4. `resources :things` generates: `GET /things`, `POST /things`, `GET /things/:id`, `PUT /things/:id`, `DELETE /things/:id`. **PUT, not PATCH.**
69
+
70
+ ## How Models Work
71
+
72
+ Models use ActiveItem (DynamoDB ORM):
73
+
74
+ ```ruby
75
+ class Post < ApplicationRecord
76
+ attr_accessor :title, :content, :status
77
+ end
78
+ ```
79
+
80
+ Table names resolve as `{APP_NAME}-{ENVIRONMENT}-{pluralized_model}` (e.g., `<%= @app_name %>-wups-posts`).
81
+
82
+ Key ActiveItem methods: `create!`, `find`, `where`, `update`, `destroy`, `all`, `count`, `exists?`.
83
+
84
+ ## How Controllers Work
85
+
86
+ Controllers inherit from `BeltController::Base`:
87
+
88
+ ```ruby
89
+ module <%= @module_name %>Controllers
90
+ class ThingsController < ApplicationController
91
+ def index
92
+ items = Thing.all
93
+ success_response(things: items.map(&:to_h))
94
+ end
95
+
96
+ def show
97
+ item = Thing.find(params[:id])
98
+ success_response(thing: item.to_h)
99
+ end
100
+
101
+ def create
102
+ item = Thing.create!(params.slice(:title, :content))
103
+ success_response(thing: item.to_h, status: 201)
104
+ end
105
+ end
106
+ end
107
+ ```
108
+
109
+ `params` contains merged path parameters and parsed JSON body. Use `success_response` and `error_response` helpers.
110
+
111
+ ## Deployment Flow
112
+
113
+ ```bash
114
+ export AWS_PROFILE=<your_profile>
115
+ belt setup state # One-time: create S3 state bucket
116
+ belt generate environment <env> # One-time: scaffold Terraform configs
117
+ belt setup tables <env> # Generate DynamoDB table resources
118
+ belt init <env> # Initialize Terraform
119
+ belt apply <env> # Deploy everything
120
+ ```
121
+
122
+ ## Adding a New Resource
123
+
124
+ ```bash
125
+ belt generate resource comment body:text author:string post_id:string
126
+ belt setup tables <env>
127
+ belt apply <env>
128
+ ```
129
+
130
+ This creates the model, controller, updates routes + schema, generates the DynamoDB table definition, and deploys.
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'activeitem'
6
+ gem 'belt'
@@ -0,0 +1,25 @@
1
+ # <%= @module_name %>
2
+
3
+ A serverless application built with [Belt](https://github.com/stowzilla/belt) and [ActiveItem](https://github.com/stowzilla/activeitem).
4
+
5
+ ## Structure
6
+
7
+ ```
8
+ ├── infrastructure/
9
+ │ ├── routes.tf.rb # API route definitions
10
+ │ └── schema.tf.rb # Model schema definitions
11
+ └── lambda/
12
+ ├── api.rb # Lambda entry point
13
+ ├── controllers/<%= @app_name %>/ # Controllers
14
+ ├── models/ # ActiveItem models
15
+ └── lib/routes/ # Route manifest
16
+ ```
17
+
18
+ ## Development
19
+
20
+ ```bash
21
+ bundle install
22
+
23
+ # Deploy
24
+ cd infrastructure && terraform apply
25
+ ```
@@ -0,0 +1,14 @@
1
+ /.bundle
2
+ /vendor/bundle
3
+ *.gem
4
+ Gemfile.lock
5
+
6
+ # Terraform
7
+ .terraform/
8
+ *.tfstate
9
+ *.tfstate.backup
10
+ .terraform.lock.hcl
11
+
12
+ # Lambda build artifacts
13
+ /lambda/vendor/
14
+ /lambda/.bundle/
@@ -0,0 +1,5 @@
1
+ TerraDispatch.routes.draw do
2
+ namespace :<%= @app_name %> do
3
+ # resources :posts
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ TerraDispatch.schema.draw do
2
+ # model :post do
3
+ # field :title, type: :string, required: true
4
+ # field :slug, type: :string, required: true
5
+ # field :content, type: :string
6
+ # field :status, type: :string, enum: %w[draft published]
7
+ # field :published_at, type: :string
8
+ # end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'activeitem'
6
+ gem 'belt'
7
+ gem 'lambda_loadout'
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'belt'
4
+ require 'activeitem'
5
+
6
+ include Belt::LambdaHandler
7
+
8
+ ActiveItem.configure do |config|
9
+ config.table_prefix = ENV['APP_NAME']
10
+ config.environment = ENV['ENVIRONMENT']
11
+ end
12
+
13
+ require_relative 'lib/routes/<%= @app_name %>_routes'
14
+ <% @resources&.each do |r| -%>
15
+ require_relative 'controllers/<%= @app_name %>/<%= r %>_controller'
16
+ <% end -%>
17
+
18
+ ROUTER = Belt::ActionRouter.new(routes: Routes::<%= @module_name.upcase %>, namespace: '<%= @app_name %>')
19
+
20
+ def execute(path:, body:, event:)
21
+ ROUTER.route(event: event, body: body)
22
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= @module_name %>Controllers
4
+ class ApplicationController < BeltController::Base
5
+ end
6
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Routes
4
+ <%= @module_name.upcase %> = [
5
+ # { verb: 'GET', path: '/posts', controller: 'posts', action: 'index' },
6
+ # { verb: 'POST', path: '/posts', controller: 'posts', action: 'create' },
7
+ # { verb: 'GET', path: '/posts/{id}', controller: 'posts', action: 'show' },
8
+ # { verb: 'PUT', path: '/posts/{id}', controller: 'posts', action: 'update' },
9
+ # { verb: 'DELETE', path: '/posts/{id}', controller: 'posts', action: 'destroy' }
10
+ ].freeze
11
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'activeitem'
4
+
5
+ class ApplicationRecord < ActiveItem::Base
6
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Concerns
4
+ module Timestampable
5
+ def self.included(base)
6
+ base.attr_accessor :created_at, :updated_at
7
+ base.set_callback :create, :before, :set_timestamps
8
+ base.set_callback :update, :before, :set_updated_at
9
+ end
10
+
11
+ private
12
+
13
+ def set_timestamps
14
+ now = Time.now.utc.iso8601
15
+ self.created_at ||= now
16
+ self.updated_at = now
17
+ end
18
+
19
+ def set_updated_at
20
+ self.updated_at = Time.now.utc.iso8601
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,38 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useParams, useNavigate } from 'react-router-dom'
3
+ import { apiClient } from '../../lib/apiClient'
4
+ import <%= @class_name %>Form from './<%= @class_name %>Form'
5
+
6
+ export default function <%= @class_name %>Edit() {
7
+ const { id } = useParams()
8
+ const navigate = useNavigate()
9
+ const [<%= @singular_name %>, set<%= @class_name %>] = useState(null)
10
+ const [loading, setLoading] = useState(true)
11
+ const [error, setError] = useState(null)
12
+
13
+ useEffect(() => {
14
+ apiClient(`/<%= @resource_name %>/${id}`)
15
+ .then(data => set<%= @class_name %>(data.<%= @singular_name %>))
16
+ .finally(() => setLoading(false))
17
+ }, [id])
18
+
19
+ async function handleSubmit(attrs) {
20
+ try {
21
+ await apiClient(`/<%= @resource_name %>/${id}`, { method: 'PUT', body: attrs })
22
+ navigate(`/<%= @resource_name %>/${id}`)
23
+ } catch (e) {
24
+ setError(e.message)
25
+ }
26
+ }
27
+
28
+ if (loading) return <p>Loading...</p>
29
+ if (!<%= @singular_name %>) return <p><%= @class_name %> not found.</p>
30
+
31
+ return (
32
+ <main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
33
+ <h1>Edit <%= @class_name %></h1>
34
+ {error && <p style={{ color: 'red' }}>{error}</p>}
35
+ <<%= @class_name %>Form initialValues={<%= @singular_name %>} onSubmit={handleSubmit} submitLabel="Update" />
36
+ </main>
37
+ )
38
+ }
@@ -0,0 +1,34 @@
1
+ import { useState } from 'react'
2
+ import { Link } from 'react-router-dom'
3
+
4
+ export default function <%= @class_name %>Form({ initialValues = {}, onSubmit, submitLabel = 'Create' }) {
5
+ <% @fields.each do |field| -%>
6
+ const [<%= field[:name] %>, set<%= field[:name].split('_').map(&:capitalize).join %>] = useState(initialValues.<%= field[:name] %> || '')
7
+ <% end -%>
8
+
9
+ function handleSubmit(e) {
10
+ e.preventDefault()
11
+ onSubmit({ <%= @fields.map { |f| f[:name] }.join(', ') %> })
12
+ }
13
+
14
+ return (
15
+ <form onSubmit={handleSubmit}>
16
+ <% @fields.each do |field| -%>
17
+ <div style={{ marginBottom: '1rem' }}>
18
+ <label style={{ display: 'block', marginBottom: '0.25rem' }}><%= field[:name].split('_').map(&:capitalize).join(' ') %></label>
19
+ <% if field[:type] == 'text' -%>
20
+ <textarea value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
21
+ rows={6} style={{ width: '100%' }} />
22
+ <% else -%>
23
+ <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' }} />
25
+ <% end -%>
26
+ </div>
27
+ <% end -%>
28
+
29
+ <button type="submit">{submitLabel}</button>
30
+ {' '}
31
+ <Link to="/<%= @resource_name %>">Cancel</Link>
32
+ </form>
33
+ )
34
+ }
@@ -0,0 +1,39 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Link } from 'react-router-dom'
3
+ import { apiClient } from '../../lib/apiClient'
4
+
5
+ export default function <%= @class_name %>sIndex() {
6
+ const [<%= @resource_name %>, set<%= @class_name %>s] = useState([])
7
+ const [loading, setLoading] = useState(true)
8
+
9
+ useEffect(() => {
10
+ apiClient('/<%= @resource_name %>')
11
+ .then(data => set<%= @class_name %>s(data.<%= @resource_name %> || []))
12
+ .finally(() => setLoading(false))
13
+ }, [])
14
+
15
+ if (loading) return <p>Loading...</p>
16
+
17
+ return (
18
+ <main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
19
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
20
+ <h1><%= @class_name %>s</h1>
21
+ <Link to="/<%= @resource_name %>/new">New <%= @class_name %></Link>
22
+ </div>
23
+
24
+ {<%= @resource_name %>.length === 0 ? (
25
+ <p>No <%= @resource_name %> yet.</p>
26
+ ) : (
27
+ <ul style={{ listStyle: 'none', padding: 0 }}>
28
+ {<%= @resource_name %>.map(<%= @singular_name %> => (
29
+ <li key={<%= @singular_name %>.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid #eee' }}>
30
+ <Link to={`/<%= @resource_name %>/${<%= @singular_name %>.id}`}>
31
+ {<%= @singular_name %>.<%= @fields.first&.dig(:name) || 'id' %>}
32
+ </Link>
33
+ </li>
34
+ ))}
35
+ </ul>
36
+ )}
37
+ </main>
38
+ )
39
+ }
@@ -0,0 +1,26 @@
1
+ import { useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { apiClient } from '../../lib/apiClient'
4
+ import <%= @class_name %>Form from './<%= @class_name %>Form'
5
+
6
+ export default function <%= @class_name %>New() {
7
+ const navigate = useNavigate()
8
+ const [error, setError] = useState(null)
9
+
10
+ async function handleSubmit(attrs) {
11
+ try {
12
+ const data = await apiClient('/<%= @resource_name %>', { method: 'POST', body: attrs })
13
+ navigate(`/<%= @resource_name %>/${data.<%= @singular_name %>.id}`)
14
+ } catch (e) {
15
+ setError(e.message)
16
+ }
17
+ }
18
+
19
+ return (
20
+ <main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
21
+ <h1>New <%= @class_name %></h1>
22
+ {error && <p style={{ color: 'red' }}>{error}</p>}
23
+ <<%= @class_name %>Form onSubmit={handleSubmit} />
24
+ </main>
25
+ )
26
+ }