gemstack-cli 0.2.5 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -25
- data/README.md +6 -17
- data/exe/gemstack +8 -1
- metadata +6 -159
- data/lib/gemstack/cli/add_generator.rb +0 -239
- data/lib/gemstack/cli/app_generator.rb +0 -142
- data/lib/gemstack/cli/commands/db.rb +0 -135
- data/lib/gemstack/cli/commands/jobs.rb +0 -96
- data/lib/gemstack/cli/controller_generator.rb +0 -73
- data/lib/gemstack/cli/deploy_generator.rb +0 -102
- data/lib/gemstack/cli/doctor.rb +0 -308
- data/lib/gemstack/cli/generator.rb +0 -144
- data/lib/gemstack/cli/job_generator.rb +0 -48
- data/lib/gemstack/cli/migration_generator.rb +0 -52
- data/lib/gemstack/cli/policy_generator.rb +0 -35
- data/lib/gemstack/cli/project.rb +0 -53
- data/lib/gemstack/cli/resource_generator.rb +0 -170
- data/lib/gemstack/cli/resource_spec.rb +0 -95
- data/lib/gemstack/cli.rb +0 -304
- data/templates/app/Gemfile.tt +0 -44
- data/templates/app/README.md.tt +0 -29
- data/templates/app/app/controllers/application_controller.rb +0 -6
- data/templates/app/app/jobs/application_job.rb +0 -9
- data/templates/app/app/mailers/application_mailer.rb +0 -8
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +0 -19
- data/templates/app/app/serializers/application_serializer.rb +0 -6
- data/templates/app/bin/gemstack +0 -7
- data/templates/app/config/app.rb.tt +0 -54
- data/templates/app/config/database.yml.tt +0 -61
- data/templates/app/config/environments/development.rb.tt +0 -35
- data/templates/app/config/environments/production.rb.tt +0 -40
- data/templates/app/config/environments/test.rb.tt +0 -21
- data/templates/app/config/puma.rb +0 -21
- data/templates/app/config/routes.rb +0 -10
- data/templates/app/config.ru +0 -6
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +0 -5
- data/templates/app/dot_env.example.tt +0 -19
- data/templates/app/dot_gitignore +0 -16
- data/templates/app/dot_node-version.tt +0 -1
- data/templates/app/dot_nvmrc.tt +0 -1
- data/templates/app/dot_ruby-version.tt +0 -1
- data/templates/app/dot_tool-versions.tt +0 -4
- data/templates/app/test/health_test.rb +0 -18
- data/templates/app/test/test_helper.rb.tt +0 -25
- data/templates/auth/app/controllers/api_tokens_controller.rb +0 -35
- data/templates/auth/app/controllers/email_verifications_controller.rb +0 -31
- data/templates/auth/app/controllers/password_resets_controller.rb +0 -40
- data/templates/auth/app/controllers/registrations_controller.rb +0 -19
- data/templates/auth/app/controllers/sessions_controller.rb +0 -29
- data/templates/auth/app/mailers/auth_mailer.rb +0 -20
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +0 -2
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +0 -2
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +0 -3
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +0 -6
- data/templates/auth/app/models/auth_token.rb +0 -15
- data/templates/auth/app/models/user.rb +0 -12
- data/templates/auth/app/policies/application_policy.rb +0 -6
- data/templates/auth/app/serializers/api_token_serializer.rb +0 -6
- data/templates/auth/app/serializers/new_api_token_serializer.rb +0 -9
- data/templates/auth/app/serializers/user_serializer.rb +0 -6
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +0 -41
- data/templates/auth/frontend/app/account/page.tsx +0 -116
- data/templates/auth/frontend/app/forgot-password/page.tsx +0 -48
- data/templates/auth/frontend/app/login/page.tsx +0 -26
- data/templates/auth/frontend/app/reset-password/page.tsx +0 -11
- data/templates/auth/frontend/app/signup/page.tsx +0 -27
- data/templates/auth/frontend/app/verify-email/page.tsx +0 -11
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +0 -58
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +0 -43
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +0 -28
- data/templates/auth/frontend/lib/auth.ts +0 -113
- data/templates/auth/test/controllers/auth_test.rb +0 -108
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +0 -10
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +0 -14
- data/templates/deploy/Caddyfile.tt +0 -19
- data/templates/deploy/Dockerfile.tt +0 -63
- data/templates/deploy/Procfile.tt +0 -6
- data/templates/deploy/compose.yaml.tt +0 -129
- data/templates/deploy/dot_dockerignore +0 -14
- data/templates/frontend/app/globals.css +0 -171
- data/templates/frontend/app/layout.tsx.tt +0 -19
- data/templates/frontend/app/page.module.css +0 -315
- data/templates/frontend/app/page.tsx.tt +0 -183
- data/templates/frontend/app/providers.tsx +0 -24
- data/templates/frontend/lib/gemstack/client.ts +0 -125
- data/templates/frontend/next-env.d.ts +0 -5
- data/templates/frontend/next.config.ts +0 -17
- data/templates/frontend/package.json.tt +0 -23
- data/templates/frontend/tsconfig.json +0 -21
- data/templates/job/app/jobs/%file_name%.rb.tt +0 -15
- data/templates/job/test/jobs/%file_name%_test.rb.tt +0 -15
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +0 -17
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +0 -21
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +0 -9
- data/templates/realtime/config/channels.rb +0 -14
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +0 -120
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +0 -51
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +0 -74
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +0 -45
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +0 -49
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +0 -27
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +0 -51
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +0 -15
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +0 -98
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +0 -36
- data/templates/resource/frontend/frontend/lib/format.ts +0 -12
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +0 -64
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +0 -18
- data/templates/resource/model/app/models/%file_name%.rb.tt +0 -15
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +0 -29
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +0 -7
- data/templates/storage/app/controllers/uploads_controller.rb.tt +0 -33
- data/templates/storage/app/serializers/upload_serializer.rb +0 -11
- data/templates/storage/frontend/lib/upload.ts +0 -52
- data/templates/storage/test/controllers/uploads_test.rb.tt +0 -30
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
-
"allowJs": false,
|
|
6
|
-
"skipLibCheck": true,
|
|
7
|
-
"strict": true,
|
|
8
|
-
"noEmit": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"module": "esnext",
|
|
11
|
-
"moduleResolution": "bundler",
|
|
12
|
-
"resolveJsonModule": true,
|
|
13
|
-
"isolatedModules": true,
|
|
14
|
-
"jsx": "react-jsx",
|
|
15
|
-
"incremental": true,
|
|
16
|
-
"plugins": [{ "name": "next" }],
|
|
17
|
-
"paths": { "@/*": ["./*"] }
|
|
18
|
-
},
|
|
19
|
-
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
|
20
|
-
"exclude": ["node_modules"]
|
|
21
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Enqueue with <%= class_name %>.perform_later(...) — arguments must be JSON values (pass ids, not records).
|
|
4
|
-
# Runs in `gemstack jobs` (and in `gemstack dev`). Delivery is at-least-once: keep perform idempotent.
|
|
5
|
-
class <%= class_name %> < ApplicationJob
|
|
6
|
-
<% if queue_name -%>
|
|
7
|
-
queue :<%= queue_name %>
|
|
8
|
-
<% end -%>
|
|
9
|
-
# retry_on SomeTimeoutError, attempts: 5 # default: any StandardError, 10 attempts, exponential backoff
|
|
10
|
-
# discard_on GemStack::NotFound # drop instead of retrying
|
|
11
|
-
|
|
12
|
-
def perform(*args)
|
|
13
|
-
GemStack.logger.info("<%= class_name %> ran", args: args)
|
|
14
|
-
end
|
|
15
|
-
end
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../test_helper"
|
|
4
|
-
|
|
5
|
-
class <%= class_name %>Test < GemStack::TestCase
|
|
6
|
-
def test_enqueue
|
|
7
|
-
<%= class_name %>.perform_later(1)
|
|
8
|
-
|
|
9
|
-
assert_enqueued <%= class_name %>, args: [1]
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def test_perform
|
|
13
|
-
perform_enqueued_jobs { <%= class_name %>.perform_later(1) }
|
|
14
|
-
end
|
|
15
|
-
end
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Sequel migration — see https://sequel.jeremyevans.net/rdoc/files/doc/schema_modification_rdoc.html
|
|
4
|
-
Sequel.migration do
|
|
5
|
-
change do
|
|
6
|
-
<% if table && !columns.empty? -%>
|
|
7
|
-
alter_table(:<%= table %>) do
|
|
8
|
-
<% columns.each do |column| -%>
|
|
9
|
-
<%= column %>
|
|
10
|
-
<% end -%>
|
|
11
|
-
end
|
|
12
|
-
<% else -%>
|
|
13
|
-
# create_table(:things) { primary_key :id, type: :Bignum }
|
|
14
|
-
# alter_table(:things) { add_column :name, String }
|
|
15
|
-
<% end -%>
|
|
16
|
-
end
|
|
17
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Who may do what with <%= model_name %> records (docs/authorization.md).
|
|
4
|
-
# `user` is the signed-in user (nil when anonymous); `record` the <%= model_name %>.
|
|
5
|
-
# In controllers: authorize!(record) checks "<action>?", policy_scope(<%= model_name %>) filters lists.
|
|
6
|
-
class <%= class_name %> < <%= parent_class %>
|
|
7
|
-
def index? = !user.nil?
|
|
8
|
-
def show? = owner?
|
|
9
|
-
def create? = !user.nil?
|
|
10
|
-
def update? = owner?
|
|
11
|
-
def destroy? = owner?
|
|
12
|
-
|
|
13
|
-
class Scope < Scope
|
|
14
|
-
# The <%= model_name %> records the user may list. Adjust to your data model.
|
|
15
|
-
def resolve = user ? scope.where(user_id: user.id) : scope.where(false)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
private
|
|
19
|
-
|
|
20
|
-
def owner? = !user.nil? && record.respond_to?(:user_id) && record.user_id == user.id
|
|
21
|
-
end
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Realtime channels browsers may subscribe to (docs/realtime.md). Anything not
|
|
4
|
-
# listed here is refused. `*` matches one segment and is passed to the block,
|
|
5
|
-
# together with the request (cookies, headers) for authorization.
|
|
6
|
-
#
|
|
7
|
-
# GemStack.broadcast("orders:#{order.id}", "order.updated", order)
|
|
8
|
-
#
|
|
9
|
-
GemStack.channels do
|
|
10
|
-
# channel "announcements" # public
|
|
11
|
-
# channel "orders:*" do |order_id, request| # private
|
|
12
|
-
# Order.find_by(id: order_id)&.user_id == current_user_id(request)
|
|
13
|
-
# end
|
|
14
|
-
end
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GemStack realtime client (yours to edit — see docs/realtime.md).
|
|
3
|
-
*
|
|
4
|
-
* const off = realtime.subscribe("orders:42", (event) => console.log(event.event, event.data));
|
|
5
|
-
* off(); // unsubscribe
|
|
6
|
-
*
|
|
7
|
-
* useRealtime("products", (event) => queryClient.invalidateQueries({ queryKey: ["products"] }));
|
|
8
|
-
*
|
|
9
|
-
* One EventSource per tab carries every channel; changes to the set of
|
|
10
|
-
* channels made in the same tick cause a single reconnect. After a dropped
|
|
11
|
-
* connection the browser reconnects with Last-Event-ID and the server replays
|
|
12
|
-
* what was missed — or sends `gemstack.gap` (delivered to every handler) when
|
|
13
|
-
* it can't, so you can refetch. A refused channel's handlers get `gemstack.denied`
|
|
14
|
-
* while the other channels keep working.
|
|
15
|
-
*/
|
|
16
|
-
"use client";
|
|
17
|
-
|
|
18
|
-
import { useEffect, useRef } from "react";
|
|
19
|
-
import { apiUrl } from "./client";
|
|
20
|
-
|
|
21
|
-
export type RealtimeEvent<T = unknown> = {
|
|
22
|
-
id: string | null;
|
|
23
|
-
channel: string | null;
|
|
24
|
-
event: string;
|
|
25
|
-
data: T;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export type RealtimeHandler<T = unknown> = (event: RealtimeEvent<T>) => void;
|
|
29
|
-
export type RealtimeStatus = "idle" | "connecting" | "open" | "reconnecting";
|
|
30
|
-
|
|
31
|
-
export const GAP_EVENT = "gemstack.gap";
|
|
32
|
-
/** Delivered to a channel's handlers when the server refuses the subscription. */
|
|
33
|
-
export const DENIED_EVENT = "gemstack.denied";
|
|
34
|
-
|
|
35
|
-
class RealtimeClient {
|
|
36
|
-
private handlers = new Map<string, Set<RealtimeHandler>>();
|
|
37
|
-
private source: EventSource | null = null;
|
|
38
|
-
private connectedKey = "";
|
|
39
|
-
private lastEventId: string | null = null;
|
|
40
|
-
private scheduled = false;
|
|
41
|
-
private statusListeners = new Set<(status: RealtimeStatus) => void>();
|
|
42
|
-
status: RealtimeStatus = "idle";
|
|
43
|
-
|
|
44
|
-
subscribe<T = unknown>(channel: string, handler: RealtimeHandler<T>): () => void {
|
|
45
|
-
const set = this.handlers.get(channel) ?? new Set<RealtimeHandler>();
|
|
46
|
-
set.add(handler as RealtimeHandler);
|
|
47
|
-
this.handlers.set(channel, set);
|
|
48
|
-
this.schedule();
|
|
49
|
-
return () => {
|
|
50
|
-
set.delete(handler as RealtimeHandler);
|
|
51
|
-
if (set.size === 0) this.handlers.delete(channel);
|
|
52
|
-
this.schedule();
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
onStatus(listener: (status: RealtimeStatus) => void): () => void {
|
|
57
|
-
this.statusListeners.add(listener);
|
|
58
|
-
return () => this.statusListeners.delete(listener);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
private setStatus(status: RealtimeStatus) {
|
|
62
|
-
this.status = status;
|
|
63
|
-
this.statusListeners.forEach((listener) => listener(status));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
private schedule() {
|
|
67
|
-
if (this.scheduled) return;
|
|
68
|
-
this.scheduled = true;
|
|
69
|
-
queueMicrotask(() => {
|
|
70
|
-
this.scheduled = false;
|
|
71
|
-
this.connect();
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private connect() {
|
|
76
|
-
if (typeof window === "undefined" || typeof EventSource === "undefined") return;
|
|
77
|
-
const channels = [...this.handlers.keys()].sort();
|
|
78
|
-
const key = channels.join(",");
|
|
79
|
-
if (key === this.connectedKey && this.source) return;
|
|
80
|
-
|
|
81
|
-
this.source?.close();
|
|
82
|
-
this.source = null;
|
|
83
|
-
this.connectedKey = key;
|
|
84
|
-
if (channels.length === 0) return this.setStatus("idle");
|
|
85
|
-
|
|
86
|
-
const query: Record<string, string> = { channels: key };
|
|
87
|
-
if (this.lastEventId) query.last_event_id = this.lastEventId;
|
|
88
|
-
const source = new EventSource(apiUrl("/realtime", query), { withCredentials: true });
|
|
89
|
-
this.source = source;
|
|
90
|
-
this.setStatus("connecting");
|
|
91
|
-
source.onopen = () => this.setStatus("open");
|
|
92
|
-
source.onerror = () => this.setStatus("reconnecting"); // the browser retries on its own
|
|
93
|
-
source.onmessage = (message) => {
|
|
94
|
-
if (message.lastEventId) this.lastEventId = message.lastEventId;
|
|
95
|
-
let event: RealtimeEvent;
|
|
96
|
-
try {
|
|
97
|
-
event = JSON.parse(message.data) as RealtimeEvent;
|
|
98
|
-
} catch {
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
const targets =
|
|
102
|
-
event.event === GAP_EVENT ? [...this.handlers.values()] : [this.handlers.get(event.channel ?? "") ?? new Set()];
|
|
103
|
-
targets.forEach((set) => set.forEach((handler) => handler(event)));
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export const realtime = new RealtimeClient();
|
|
109
|
-
|
|
110
|
-
/** Subscribes while the component is mounted. Pass null to skip. */
|
|
111
|
-
export function useRealtime<T = unknown>(channel: string | null, handler: RealtimeHandler<T>) {
|
|
112
|
-
const latest = useRef(handler);
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
latest.current = handler; // always call the newest handler without resubscribing
|
|
115
|
-
});
|
|
116
|
-
useEffect(() => {
|
|
117
|
-
if (!channel) return;
|
|
118
|
-
return realtime.subscribe<T>(channel, (event) => latest.current(event));
|
|
119
|
-
}, [channel]);
|
|
120
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
class <%= spec.plural_class %>Controller < ApplicationController
|
|
4
|
-
<% if spec.action?(:index) -%>
|
|
5
|
-
returns :index, GemStack::Page[<%= class_name %>Serializer]
|
|
6
|
-
<% end -%>
|
|
7
|
-
<% if spec.action?(:create) -%>
|
|
8
|
-
accepts :create, with: <%= class_name %>.input_schema
|
|
9
|
-
<% end -%>
|
|
10
|
-
<% if spec.action?(:update) -%>
|
|
11
|
-
accepts :update, with: <%= class_name %>.input_schema, partial: true
|
|
12
|
-
<% end -%>
|
|
13
|
-
<% if spec.action?(:index) -%>
|
|
14
|
-
|
|
15
|
-
# GET /api/<%= spec.url_segment %>?page=1&per_page=25
|
|
16
|
-
def index
|
|
17
|
-
render paginate(<%= class_name %>.order(:id))
|
|
18
|
-
end
|
|
19
|
-
<% end -%>
|
|
20
|
-
<% if spec.action?(:show) -%>
|
|
21
|
-
|
|
22
|
-
# GET /api/<%= spec.url_segment %>/:id
|
|
23
|
-
def show
|
|
24
|
-
render <%= class_name %>.find(params[:id])
|
|
25
|
-
end
|
|
26
|
-
<% end -%>
|
|
27
|
-
<% if spec.action?(:create) -%>
|
|
28
|
-
|
|
29
|
-
# POST /api/<%= spec.url_segment %>
|
|
30
|
-
def create
|
|
31
|
-
render <%= class_name %>.create(input), status: :created
|
|
32
|
-
end
|
|
33
|
-
<% end -%>
|
|
34
|
-
<% if spec.action?(:update) -%>
|
|
35
|
-
|
|
36
|
-
# PATCH /api/<%= spec.url_segment %>/:id
|
|
37
|
-
def update
|
|
38
|
-
<%= spec.variable %> = <%= class_name %>.find(params[:id])
|
|
39
|
-
<%= spec.variable %>.update(input)
|
|
40
|
-
render <%= spec.variable %>
|
|
41
|
-
end
|
|
42
|
-
<% end -%>
|
|
43
|
-
<% if spec.action?(:destroy) -%>
|
|
44
|
-
|
|
45
|
-
# DELETE /api/<%= spec.url_segment %>/:id
|
|
46
|
-
def destroy
|
|
47
|
-
<%= class_name %>.find(params[:id]).destroy
|
|
48
|
-
head :no_content
|
|
49
|
-
end
|
|
50
|
-
<% end -%>
|
|
51
|
-
end
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../test_helper"
|
|
4
|
-
|
|
5
|
-
class <%= spec.plural_class %>ControllerTest < GemStack::TestCase
|
|
6
|
-
# Valid values derived from <%= class_name %>'s field declarations — edit freely.
|
|
7
|
-
def create_<%= spec.file_name %> = <%= class_name %>.create(GemStack::DB::Testing.sample_attributes(<%= class_name %>))
|
|
8
|
-
def payload = GemStack::DB::Testing.sample_payload(<%= class_name %>)
|
|
9
|
-
<% if spec.action?(:index) -%>
|
|
10
|
-
|
|
11
|
-
def test_index
|
|
12
|
-
create_<%= spec.file_name %>
|
|
13
|
-
get_json "/api/<%= spec.url_segment %>"
|
|
14
|
-
|
|
15
|
-
assert_status 200
|
|
16
|
-
assert_equal 1, json_body["data"].size
|
|
17
|
-
assert_equal({ "page" => 1, "per_page" => 25, "total" => 1, "total_pages" => 1 }, json_body["meta"])
|
|
18
|
-
end
|
|
19
|
-
<% end -%>
|
|
20
|
-
<% if spec.action?(:show) -%>
|
|
21
|
-
|
|
22
|
-
def test_show
|
|
23
|
-
<%= spec.variable %> = create_<%= spec.file_name %>
|
|
24
|
-
get_json "/api/<%= spec.url_segment %>/#{<%= spec.variable %>.id}"
|
|
25
|
-
|
|
26
|
-
assert_status 200
|
|
27
|
-
assert_equal <%= spec.variable %>.id, json_body["id"]
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def test_show_missing
|
|
31
|
-
get_json "/api/<%= spec.url_segment %>/0"
|
|
32
|
-
|
|
33
|
-
assert_error 404, "not_found"
|
|
34
|
-
end
|
|
35
|
-
<% end -%>
|
|
36
|
-
<% if spec.action?(:create) -%>
|
|
37
|
-
|
|
38
|
-
def test_create
|
|
39
|
-
post_json "/api/<%= spec.url_segment %>", payload
|
|
40
|
-
|
|
41
|
-
assert_status 201
|
|
42
|
-
assert <%= class_name %>[json_body["id"]]
|
|
43
|
-
end
|
|
44
|
-
<% if fields.any?(&:required?) -%>
|
|
45
|
-
|
|
46
|
-
def test_create_with_invalid_input
|
|
47
|
-
post_json "/api/<%= spec.url_segment %>", {}
|
|
48
|
-
|
|
49
|
-
assert_error 422, "validation_failed"
|
|
50
|
-
assert_equal ["is required"], json_body["errors"]["<%= fields.find(&:required?).column %>"]
|
|
51
|
-
end
|
|
52
|
-
<% end -%>
|
|
53
|
-
<% end -%>
|
|
54
|
-
<% if spec.action?(:update) -%>
|
|
55
|
-
|
|
56
|
-
def test_update
|
|
57
|
-
<%= spec.variable %> = create_<%= spec.file_name %>
|
|
58
|
-
patch_json "/api/<%= spec.url_segment %>/#{<%= spec.variable %>.id}", payload
|
|
59
|
-
|
|
60
|
-
assert_status 200
|
|
61
|
-
assert_equal <%= spec.variable %>.id, json_body["id"]
|
|
62
|
-
end
|
|
63
|
-
<% end -%>
|
|
64
|
-
<% if spec.action?(:destroy) -%>
|
|
65
|
-
|
|
66
|
-
def test_destroy
|
|
67
|
-
<%= spec.variable %> = create_<%= spec.file_name %>
|
|
68
|
-
delete_json "/api/<%= spec.url_segment %>/#{<%= spec.variable %>.id}"
|
|
69
|
-
|
|
70
|
-
assert_status 204
|
|
71
|
-
assert_nil <%= class_name %>[<%= spec.variable %>.id]
|
|
72
|
-
end
|
|
73
|
-
<% end -%>
|
|
74
|
-
end
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { useParams, useRouter } from "next/navigation";
|
|
5
|
-
import { <%= class_name %>Form } from "@/components/<%= spec.url_segment %>/<%= class_name %>Form";
|
|
6
|
-
import { <%= spec.action?(:show) ? "use#{class_name}, " : "" %>useUpdate<%= class_name %> } from "@/lib/queries/<%= spec.url_segment %>";
|
|
7
|
-
|
|
8
|
-
export default function Edit<%= class_name %>Page() {
|
|
9
|
-
const { id } = useParams<{ id: string }>();
|
|
10
|
-
const router = useRouter();
|
|
11
|
-
<% if spec.action?(:show) -%>
|
|
12
|
-
const <%= spec.variable %> = use<%= class_name %>(id);
|
|
13
|
-
<% end -%>
|
|
14
|
-
const update = useUpdate<%= class_name %>(id);
|
|
15
|
-
|
|
16
|
-
return (
|
|
17
|
-
<main className="page">
|
|
18
|
-
<p>
|
|
19
|
-
<Link href="/<%= spec.url_segment %><%= spec.action?(:show) ? "/${id}" : "" %>">← Back</Link>
|
|
20
|
-
</p>
|
|
21
|
-
<h1>Edit <%= spec.human.downcase %></h1>
|
|
22
|
-
<% if spec.action?(:show) -%>
|
|
23
|
-
{<%= spec.variable %>.isPending && <p className="muted">Loading…</p>}
|
|
24
|
-
{<%= spec.variable %>.data && (
|
|
25
|
-
<<%= class_name %>Form
|
|
26
|
-
<%= spec.variable %>={<%= spec.variable %>.data}
|
|
27
|
-
submitLabel="Save changes"
|
|
28
|
-
onSubmit={async (input) => {
|
|
29
|
-
await update.mutateAsync(input);
|
|
30
|
-
router.push(`/<%= spec.url_segment %>/${id}`);
|
|
31
|
-
}}
|
|
32
|
-
/>
|
|
33
|
-
)}
|
|
34
|
-
<% else -%>
|
|
35
|
-
<<%= class_name %>Form
|
|
36
|
-
submitLabel="Save changes"
|
|
37
|
-
onSubmit={async (input) => {
|
|
38
|
-
await update.mutateAsync(input);
|
|
39
|
-
router.push("/<%= spec.url_segment %>");
|
|
40
|
-
}}
|
|
41
|
-
/>
|
|
42
|
-
<% end -%>
|
|
43
|
-
</main>
|
|
44
|
-
);
|
|
45
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { useParams<%= spec.action?(:destroy) ? ", useRouter" : "" %> } from "next/navigation";
|
|
5
|
-
import { <%= class_name %>Card } from "@/components/<%= spec.url_segment %>/<%= class_name %>Card";
|
|
6
|
-
import { <%= ["use#{class_name}", (spec.action?(:destroy) ? "useDelete#{class_name}" : nil)].compact.join(", ") %> } from "@/lib/queries/<%= spec.url_segment %>";
|
|
7
|
-
|
|
8
|
-
export default function <%= class_name %>Page() {
|
|
9
|
-
const { id } = useParams<{ id: string }>();
|
|
10
|
-
const <%= spec.variable %> = use<%= class_name %>(id);
|
|
11
|
-
<% if spec.action?(:destroy) -%>
|
|
12
|
-
const router = useRouter();
|
|
13
|
-
const remove = useDelete<%= class_name %>();
|
|
14
|
-
|
|
15
|
-
async function handleDelete() {
|
|
16
|
-
if (!window.confirm("Delete this <%= spec.human.downcase %>?")) return;
|
|
17
|
-
await remove.mutateAsync(id);
|
|
18
|
-
router.push("/<%= spec.url_segment %>");
|
|
19
|
-
}
|
|
20
|
-
<% end -%>
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
<main className="page">
|
|
24
|
-
<p>
|
|
25
|
-
<Link href="/<%= spec.url_segment %>">← <%= spec.human_plural %></Link>
|
|
26
|
-
</p>
|
|
27
|
-
{<%= spec.variable %>.isPending && <p className="muted">Loading…</p>}
|
|
28
|
-
{<%= spec.variable %>.isError && <p className="form-error">{<%= spec.variable %>.error.message}</p>}
|
|
29
|
-
{<%= spec.variable %>.data && (
|
|
30
|
-
<>
|
|
31
|
-
<h1><%= spec.human %> #{<%= spec.variable %>.data.id}</h1>
|
|
32
|
-
<<%= class_name %>Card <%= spec.variable %>={<%= spec.variable %>.data} />
|
|
33
|
-
<div className="actions">
|
|
34
|
-
<% if spec.action?(:update) -%>
|
|
35
|
-
<Link className="button" href={`/<%= spec.url_segment %>/${id}/edit`}>
|
|
36
|
-
Edit
|
|
37
|
-
</Link>
|
|
38
|
-
<% end -%>
|
|
39
|
-
<% if spec.action?(:destroy) -%>
|
|
40
|
-
<button className="button button-danger" type="button" onClick={handleDelete} disabled={remove.isPending}>
|
|
41
|
-
Delete
|
|
42
|
-
</button>
|
|
43
|
-
<% end -%>
|
|
44
|
-
</div>
|
|
45
|
-
</>
|
|
46
|
-
)}
|
|
47
|
-
</main>
|
|
48
|
-
);
|
|
49
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { useRouter } from "next/navigation";
|
|
5
|
-
import { <%= class_name %>Form } from "@/components/<%= spec.url_segment %>/<%= class_name %>Form";
|
|
6
|
-
import { useCreate<%= class_name %> } from "@/lib/queries/<%= spec.url_segment %>";
|
|
7
|
-
|
|
8
|
-
export default function New<%= class_name %>Page() {
|
|
9
|
-
const router = useRouter();
|
|
10
|
-
const create = useCreate<%= class_name %>();
|
|
11
|
-
|
|
12
|
-
return (
|
|
13
|
-
<main className="page">
|
|
14
|
-
<p>
|
|
15
|
-
<Link href="/<%= spec.url_segment %>">← <%= spec.human_plural %></Link>
|
|
16
|
-
</p>
|
|
17
|
-
<h1>New <%= spec.human.downcase %></h1>
|
|
18
|
-
<<%= class_name %>Form
|
|
19
|
-
submitLabel="Create <%= spec.human.downcase %>"
|
|
20
|
-
onSubmit={async (input) => {
|
|
21
|
-
const <%= spec.variable %> = await create.mutateAsync(input);
|
|
22
|
-
router.push(<%= spec.action?(:show) ? "`/#{spec.url_segment}/${#{spec.variable}.id}`" : "\"/#{spec.url_segment}\"" %>);
|
|
23
|
-
}}
|
|
24
|
-
/>
|
|
25
|
-
</main>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { useSearchParams } from "next/navigation";
|
|
5
|
-
import { Suspense } from "react";
|
|
6
|
-
import { <%= class_name %>Table } from "@/components/<%= spec.url_segment %>/<%= class_name %>Table";
|
|
7
|
-
import { use<%= spec.plural_class %> } from "@/lib/queries/<%= spec.url_segment %>";
|
|
8
|
-
|
|
9
|
-
export default function <%= spec.plural_class %>Page() {
|
|
10
|
-
return (
|
|
11
|
-
<main className="page">
|
|
12
|
-
<header className="page-header">
|
|
13
|
-
<h1><%= spec.human_plural %></h1>
|
|
14
|
-
<% if spec.action?(:create) -%>
|
|
15
|
-
<Link className="button" href="/<%= spec.url_segment %>/new">
|
|
16
|
-
New <%= spec.human.downcase %>
|
|
17
|
-
</Link>
|
|
18
|
-
<% end -%>
|
|
19
|
-
</header>
|
|
20
|
-
{/* useSearchParams needs a Suspense boundary for static rendering. */}
|
|
21
|
-
<Suspense>
|
|
22
|
-
<<%= spec.plural_class %>List />
|
|
23
|
-
</Suspense>
|
|
24
|
-
</main>
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// The current page lives in the URL (?page=2), so it survives reloads and can be shared.
|
|
29
|
-
function <%= spec.plural_class %>List() {
|
|
30
|
-
const page = Math.max(1, Number(useSearchParams().get("page")) || 1);
|
|
31
|
-
const <%= spec.plural %> = use<%= spec.plural_class %>(page);
|
|
32
|
-
|
|
33
|
-
if (<%= spec.plural %>.isPending) return <p className="muted">Loading…</p>;
|
|
34
|
-
if (<%= spec.plural %>.isError) return <p className="form-error">Could not load <%= spec.human_plural.downcase %>: {<%= spec.plural %>.error.message}</p>;
|
|
35
|
-
|
|
36
|
-
const { data, meta } = <%= spec.plural %>.data;
|
|
37
|
-
return (
|
|
38
|
-
<>
|
|
39
|
-
<<%= class_name %>Table <%= spec.plural %>={data} />
|
|
40
|
-
{meta.total_pages > 1 && (
|
|
41
|
-
<nav className="actions" aria-label="Pagination">
|
|
42
|
-
{page > 1 && <Link href={`?page=${page - 1}`}>← Previous</Link>}
|
|
43
|
-
<span className="muted">
|
|
44
|
-
Page {meta.page} of {meta.total_pages}
|
|
45
|
-
</span>
|
|
46
|
-
{page < meta.total_pages && <Link href={`?page=${page + 1}`}>Next →</Link>}
|
|
47
|
-
</nav>
|
|
48
|
-
)}
|
|
49
|
-
</>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { display } from "@/lib/format";
|
|
2
|
-
import type { <%= class_name %> } from "@/lib/api/generated";
|
|
3
|
-
|
|
4
|
-
export function <%= class_name %>Card({ <%= spec.variable %> }: { <%= spec.variable %>: <%= class_name %> }) {
|
|
5
|
-
return (
|
|
6
|
-
<dl className="card">
|
|
7
|
-
<% fields.each do |field| -%>
|
|
8
|
-
<dt><%= field.label %></dt>
|
|
9
|
-
<dd>{display(<%= spec.variable %>.<%= field.column %>)}</dd>
|
|
10
|
-
<% end -%>
|
|
11
|
-
<dt>Created</dt>
|
|
12
|
-
<dd>{new Date(<%= spec.variable %>.created_at).toLocaleString()}</dd>
|
|
13
|
-
</dl>
|
|
14
|
-
);
|
|
15
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, type ChangeEvent, type FormEvent } from "react";
|
|
4
|
-
import { ApiError } from "@/lib/gemstack/client";
|
|
5
|
-
import type { FieldErrors } from "@/lib/format";
|
|
6
|
-
import type { <%= class_name %>, <%= input_type %> } from "@/lib/api/generated";
|
|
7
|
-
|
|
8
|
-
type Values = {
|
|
9
|
-
<% fields.each do |field| -%>
|
|
10
|
-
<%= field.column %>: <%= ts_value_type(field) %>;
|
|
11
|
-
<% end -%>
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
function initialValues(record?: <%= class_name %>): Values {
|
|
15
|
-
return {
|
|
16
|
-
<% fields.each do |field| -%>
|
|
17
|
-
<%= field.column %>: <%= form_initial(field) %>,
|
|
18
|
-
<% end -%>
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
<% if uses_json? -%>
|
|
22
|
-
|
|
23
|
-
function parseJson(text: string): unknown {
|
|
24
|
-
try {
|
|
25
|
-
return JSON.parse(text);
|
|
26
|
-
} catch {
|
|
27
|
-
return text;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
<% end -%>
|
|
31
|
-
|
|
32
|
-
// Form values are strings; the API validates and coerces them (see <%= class_name %>.input_schema).
|
|
33
|
-
function toInput(values: Values): <%= input_type %> {
|
|
34
|
-
return {
|
|
35
|
-
<% fields.each do |field| -%>
|
|
36
|
-
<%= field.column %>: <%= form_output(field) %>,
|
|
37
|
-
<% end -%>
|
|
38
|
-
} as unknown as <%= input_type %>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function <%= class_name %>Form({
|
|
42
|
-
<%= spec.variable %>,
|
|
43
|
-
submitLabel,
|
|
44
|
-
onSubmit,
|
|
45
|
-
}: {
|
|
46
|
-
<%= spec.variable %>?: <%= class_name %>;
|
|
47
|
-
submitLabel: string;
|
|
48
|
-
onSubmit: (input: <%= input_type %>) => Promise<unknown>;
|
|
49
|
-
}) {
|
|
50
|
-
const [values, setValues] = useState<Values>(() => initialValues(<%= spec.variable %>));
|
|
51
|
-
const [errors, setErrors] = useState<FieldErrors>({});
|
|
52
|
-
const [formError, setFormError] = useState<string | null>(null);
|
|
53
|
-
const [pending, setPending] = useState(false);
|
|
54
|
-
|
|
55
|
-
const set = (key: keyof Values) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
56
|
-
const target = event.target;
|
|
57
|
-
const value = target instanceof HTMLInputElement && target.type === "checkbox" ? target.checked : target.value;
|
|
58
|
-
// Checkboxes give booleans, other inputs strings — matching each field's type in Values.
|
|
59
|
-
setValues((current) => ({ ...current, [key]: value }) as Values);
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
63
|
-
event.preventDefault();
|
|
64
|
-
setPending(true);
|
|
65
|
-
setErrors({});
|
|
66
|
-
setFormError(null);
|
|
67
|
-
try {
|
|
68
|
-
await onSubmit(toInput(values));
|
|
69
|
-
} catch (error) {
|
|
70
|
-
if (error instanceof ApiError) {
|
|
71
|
-
setErrors(error.errors);
|
|
72
|
-
setFormError(Object.keys(error.errors).length ? null : error.message);
|
|
73
|
-
} else {
|
|
74
|
-
setFormError("Something went wrong. Please try again.");
|
|
75
|
-
}
|
|
76
|
-
} finally {
|
|
77
|
-
setPending(false);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return (
|
|
82
|
-
<form className="form" onSubmit={handleSubmit} noValidate>
|
|
83
|
-
{formError && <p className="form-error" role="alert">{formError}</p>}
|
|
84
|
-
<% fields.each do |field| -%>
|
|
85
|
-
<div className="field">
|
|
86
|
-
<label htmlFor="<%= field.column %>"><%= field.label %></label>
|
|
87
|
-
<%= form_control(field) %>
|
|
88
|
-
{errors.<%= field.column %>?.map((message) => (
|
|
89
|
-
<p key={message} className="field-error">{message}</p>
|
|
90
|
-
))}
|
|
91
|
-
</div>
|
|
92
|
-
<% end -%>
|
|
93
|
-
<button className="button" type="submit" disabled={pending}>
|
|
94
|
-
{pending ? "Saving…" : submitLabel}
|
|
95
|
-
</button>
|
|
96
|
-
</form>
|
|
97
|
-
);
|
|
98
|
-
}
|