gemstack-cli 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 (110) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +24 -0
  5. data/exe/gemstack +6 -0
  6. data/lib/gemstack/cli/add_generator.rb +237 -0
  7. data/lib/gemstack/cli/app_generator.rb +110 -0
  8. data/lib/gemstack/cli/commands/db.rb +134 -0
  9. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  10. data/lib/gemstack/cli/controller_generator.rb +73 -0
  11. data/lib/gemstack/cli/deploy_generator.rb +64 -0
  12. data/lib/gemstack/cli/doctor.rb +277 -0
  13. data/lib/gemstack/cli/generator.rb +122 -0
  14. data/lib/gemstack/cli/job_generator.rb +47 -0
  15. data/lib/gemstack/cli/migration_generator.rb +52 -0
  16. data/lib/gemstack/cli/policy_generator.rb +35 -0
  17. data/lib/gemstack/cli/project.rb +53 -0
  18. data/lib/gemstack/cli/resource_generator.rb +169 -0
  19. data/lib/gemstack/cli/resource_spec.rb +95 -0
  20. data/lib/gemstack/cli.rb +299 -0
  21. data/templates/app/Gemfile.tt +39 -0
  22. data/templates/app/README.md.tt +29 -0
  23. data/templates/app/app/controllers/application_controller.rb +6 -0
  24. data/templates/app/bin/gemstack +7 -0
  25. data/templates/app/config/app.rb.tt +30 -0
  26. data/templates/app/config/environments/development.rb +6 -0
  27. data/templates/app/config/environments/production.rb +8 -0
  28. data/templates/app/config/environments/test.rb +7 -0
  29. data/templates/app/config/puma.rb +21 -0
  30. data/templates/app/config/routes.rb +10 -0
  31. data/templates/app/config.ru +6 -0
  32. data/templates/app/db/migrations/dot_keep +0 -0
  33. data/templates/app/db/seeds.rb +5 -0
  34. data/templates/app/dot_env.example +9 -0
  35. data/templates/app/dot_gitignore +14 -0
  36. data/templates/app/dot_ruby-version.tt +1 -0
  37. data/templates/app/dot_tool-versions.tt +1 -0
  38. data/templates/app/test/health_test.rb +18 -0
  39. data/templates/app/test/test_helper.rb.tt +25 -0
  40. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  41. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  42. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  43. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  44. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  45. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  46. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  47. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  48. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  49. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  50. data/templates/auth/app/models/auth_token.rb +15 -0
  51. data/templates/auth/app/models/user.rb +12 -0
  52. data/templates/auth/app/policies/application_policy.rb +6 -0
  53. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  54. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  55. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  56. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  57. data/templates/auth/frontend/app/account/page.tsx +116 -0
  58. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  59. data/templates/auth/frontend/app/login/page.tsx +26 -0
  60. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  61. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  62. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  63. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  64. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  65. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  66. data/templates/auth/frontend/lib/auth.ts +113 -0
  67. data/templates/auth/test/controllers/auth_test.rb +108 -0
  68. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  69. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  70. data/templates/deploy/Caddyfile.tt +19 -0
  71. data/templates/deploy/Dockerfile.tt +62 -0
  72. data/templates/deploy/Procfile.tt +6 -0
  73. data/templates/deploy/compose.yaml.tt +80 -0
  74. data/templates/deploy/dot_dockerignore +14 -0
  75. data/templates/frontend/app/globals.css +171 -0
  76. data/templates/frontend/app/layout.tsx.tt +19 -0
  77. data/templates/frontend/app/page.tsx.tt +45 -0
  78. data/templates/frontend/app/providers.tsx +24 -0
  79. data/templates/frontend/lib/gemstack/client.ts +125 -0
  80. data/templates/frontend/next-env.d.ts +5 -0
  81. data/templates/frontend/next.config.ts +17 -0
  82. data/templates/frontend/package.json.tt +23 -0
  83. data/templates/frontend/tsconfig.json +21 -0
  84. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  85. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  86. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  87. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  88. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  89. data/templates/realtime/config/channels.rb +14 -0
  90. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  91. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  92. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  93. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  94. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  95. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  96. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  97. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  98. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  99. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  100. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  101. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
  102. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  103. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  104. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  105. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  106. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  107. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  108. data/templates/storage/frontend/lib/upload.ts +52 -0
  109. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  110. metadata +195 -0
@@ -0,0 +1,120 @@
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
+ }
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,45 @@
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
+ }
@@ -0,0 +1,49 @@
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
+ }
@@ -0,0 +1,27 @@
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
+ }
@@ -0,0 +1,51 @@
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
+ }
@@ -0,0 +1,15 @@
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
+ }
@@ -0,0 +1,98 @@
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
+ }
@@ -0,0 +1,36 @@
1
+ import Link from "next/link";
2
+ import { display } from "@/lib/format";
3
+ import type { <%= class_name %> } from "@/lib/api/generated";
4
+
5
+ export function <%= class_name %>Table({ <%= spec.plural %> }: { <%= spec.plural %>: <%= class_name %>[] }) {
6
+ if (<%= spec.plural %>.length === 0) return <p className="muted">No <%= spec.human_plural.downcase %> yet.</p>;
7
+
8
+ return (
9
+ <table className="table">
10
+ <thead>
11
+ <tr>
12
+ <% table_fields.each do |field| -%>
13
+ <th><%= field.label %></th>
14
+ <% end -%>
15
+ <% if spec.action?(:show) -%>
16
+ <th aria-label="Actions" />
17
+ <% end -%>
18
+ </tr>
19
+ </thead>
20
+ <tbody>
21
+ {<%= spec.plural %>.map((<%= spec.variable %>) => (
22
+ <tr key={<%= spec.variable %>.id}>
23
+ <% table_fields.each do |field| -%>
24
+ <td>{display(<%= spec.variable %>.<%= field.column %>)}</td>
25
+ <% end -%>
26
+ <% if spec.action?(:show) -%>
27
+ <td>
28
+ <Link href={`/<%= spec.url_segment %>/${<%= spec.variable %>.id}`}>View</Link>
29
+ </td>
30
+ <% end -%>
31
+ </tr>
32
+ ))}
33
+ </tbody>
34
+ </table>
35
+ );
36
+ }
@@ -0,0 +1,12 @@
1
+ // Display helpers shared by generated components.
2
+
3
+ /** Human-readable text for any API value. */
4
+ export function display(value: unknown): string {
5
+ if (value === null || value === undefined || value === "") return "—";
6
+ if (typeof value === "boolean") return value ? "Yes" : "No";
7
+ if (typeof value === "object") return JSON.stringify(value);
8
+ return String(value);
9
+ }
10
+
11
+ /** Field errors from an ApiError, keyed by attribute name. */
12
+ export type FieldErrors = Record<string, string[]>;
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ // TanStack Query hooks for <%= spec.human_plural.downcase %>, built on the generated API client.
4
+ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { <%= spec.client_name %> } from "@/lib/api/generated";
6
+ import type { <%= [class_name, (spec.action?(:create) ? "#{class_name}Input" : nil), (spec.action?(:update) ? "#{class_name}UpdateInput" : nil)].compact.join(", ") %> } from "@/lib/api/generated";
7
+
8
+ export const <%= spec.variable %>Keys = {
9
+ all: ["<%= spec.plural %>"] as const,
10
+ detail: (id: string | number) => ["<%= spec.plural %>", String(id)] as const,
11
+ };
12
+ <% if spec.action?(:index) -%>
13
+
14
+ export function use<%= spec.plural_class %>(page = 1) {
15
+ return useQuery({
16
+ queryKey: [...<%= spec.variable %>Keys.all, "page", page],
17
+ queryFn: () => <%= spec.client_name %>.list({ page }),
18
+ placeholderData: keepPreviousData, // keep the current page visible while the next one loads
19
+ });
20
+ }
21
+ <% end -%>
22
+ <% if spec.action?(:show) -%>
23
+
24
+ export function use<%= class_name %>(id: string | number) {
25
+ return useQuery({ queryKey: <%= spec.variable %>Keys.detail(id), queryFn: () => <%= spec.client_name %>.get(id) });
26
+ }
27
+ <% end -%>
28
+ <% if spec.action?(:create) -%>
29
+
30
+ export function useCreate<%= class_name %>() {
31
+ const queryClient = useQueryClient();
32
+ return useMutation({
33
+ mutationFn: (data: <%= class_name %>Input) => <%= spec.client_name %>.create(data),
34
+ onSuccess: (<%= spec.variable %>: <%= class_name %>) => {
35
+ queryClient.setQueryData(<%= spec.variable %>Keys.detail(<%= spec.variable %>.id), <%= spec.variable %>);
36
+ return queryClient.invalidateQueries({ queryKey: <%= spec.variable %>Keys.all });
37
+ },
38
+ });
39
+ }
40
+ <% end -%>
41
+ <% if spec.action?(:update) -%>
42
+
43
+ export function useUpdate<%= class_name %>(id: string | number) {
44
+ const queryClient = useQueryClient();
45
+ return useMutation({
46
+ mutationFn: (data: <%= class_name %>UpdateInput) => <%= spec.client_name %>.update(id, data),
47
+ onSuccess: (<%= spec.variable %>: <%= class_name %>) => {
48
+ queryClient.setQueryData(<%= spec.variable %>Keys.detail(id), <%= spec.variable %>);
49
+ return queryClient.invalidateQueries({ queryKey: <%= spec.variable %>Keys.all });
50
+ },
51
+ });
52
+ }
53
+ <% end -%>
54
+ <% if spec.action?(:destroy) -%>
55
+
56
+ export function useDelete<%= class_name %>() {
57
+ const queryClient = useQueryClient();
58
+ return useMutation({
59
+ mutationFn: (id: string | number) => <%= spec.client_name %>.delete(id),
60
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: <%= spec.variable %>Keys.all }),
61
+ });
62
+ }
63
+ <% end -%>
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ create_table(:<%= spec.table %>) do
6
+ primary_key :id, type: :Bignum
7
+ <% fields.each do |field| -%>
8
+ <%= migration_column(field) %>
9
+ <% end -%>
10
+ column :created_at, :timestamptz, null: false
11
+ column :updated_at, :timestamptz, null: false
12
+ <% fields.select { |f| f.index && !f.unique }.each do |field| -%>
13
+
14
+ index :<%= field.column %>
15
+ <% end -%>
16
+ end
17
+ end
18
+ end