gemstack 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 +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +251 -39
|
@@ -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,64 @@
|
|
|
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
|
+
// perPage: records per page; omit it to use the API's default (config.http.pagination.per_page).
|
|
15
|
+
export function use<%= spec.plural_class %>(page = 1, perPage?: number) {
|
|
16
|
+
return useQuery({
|
|
17
|
+
queryKey: [...<%= spec.variable %>Keys.all, "page", page, perPage],
|
|
18
|
+
queryFn: () => <%= spec.client_name %>.list({ page, per_page: perPage }),
|
|
19
|
+
placeholderData: keepPreviousData, // keep the current page visible while the next one loads
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
<% end -%>
|
|
23
|
+
<% if spec.action?(:show) -%>
|
|
24
|
+
|
|
25
|
+
export function use<%= class_name %>(id: string | number) {
|
|
26
|
+
return useQuery({ queryKey: <%= spec.variable %>Keys.detail(id), queryFn: () => <%= spec.client_name %>.get(id) });
|
|
27
|
+
}
|
|
28
|
+
<% end -%>
|
|
29
|
+
<% if spec.action?(:create) -%>
|
|
30
|
+
|
|
31
|
+
export function useCreate<%= class_name %>() {
|
|
32
|
+
const queryClient = useQueryClient();
|
|
33
|
+
return useMutation({
|
|
34
|
+
mutationFn: (data: <%= class_name %>Input) => <%= spec.client_name %>.create(data),
|
|
35
|
+
onSuccess: (<%= spec.variable %>: <%= class_name %>) => {
|
|
36
|
+
queryClient.setQueryData(<%= spec.variable %>Keys.detail(<%= spec.variable %>.id), <%= spec.variable %>);
|
|
37
|
+
return queryClient.invalidateQueries({ queryKey: <%= spec.variable %>Keys.all });
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
<% end -%>
|
|
42
|
+
<% if spec.action?(:update) -%>
|
|
43
|
+
|
|
44
|
+
export function useUpdate<%= class_name %>(id: string | number) {
|
|
45
|
+
const queryClient = useQueryClient();
|
|
46
|
+
return useMutation({
|
|
47
|
+
mutationFn: (data: <%= class_name %>UpdateInput) => <%= spec.client_name %>.update(id, data),
|
|
48
|
+
onSuccess: (<%= spec.variable %>: <%= class_name %>) => {
|
|
49
|
+
queryClient.setQueryData(<%= spec.variable %>Keys.detail(id), <%= spec.variable %>);
|
|
50
|
+
return queryClient.invalidateQueries({ queryKey: <%= spec.variable %>Keys.all });
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
<% end -%>
|
|
55
|
+
<% if spec.action?(:destroy) -%>
|
|
56
|
+
|
|
57
|
+
export function useDelete<%= class_name %>() {
|
|
58
|
+
const queryClient = useQueryClient();
|
|
59
|
+
return useMutation({
|
|
60
|
+
mutationFn: (id: string | number) => <%= spec.client_name %>.delete(id),
|
|
61
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: <%= spec.variable %>Keys.all }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
<% 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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= class_name %> < ApplicationModel
|
|
4
|
+
<% fields.each do |field| -%>
|
|
5
|
+
<%= model_field(field) %>
|
|
6
|
+
<% end -%>
|
|
7
|
+
<% fields.select(&:unique).each do |field| -%>
|
|
8
|
+
|
|
9
|
+
validates :<%= field.column %>, uniqueness: true
|
|
10
|
+
<% end -%>
|
|
11
|
+
<% spec.references.each do |field| -%>
|
|
12
|
+
|
|
13
|
+
belongs_to :<%= field.name %>
|
|
14
|
+
<% end -%>
|
|
15
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class <%= class_name %>Test < GemStack::TestCase
|
|
6
|
+
def sample = GemStack::DB::Testing.sample_attributes(<%= class_name %>)
|
|
7
|
+
|
|
8
|
+
def test_valid_with_sample_attributes
|
|
9
|
+
<%= spec.variable %> = <%= class_name %>.new(sample)
|
|
10
|
+
|
|
11
|
+
assert_predicate <%= spec.variable %>, :valid?, <%= spec.variable %>.errors.to_h.inspect
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_create
|
|
15
|
+
<%= spec.variable %> = <%= class_name %>.create(sample)
|
|
16
|
+
|
|
17
|
+
assert <%= spec.variable %>.id
|
|
18
|
+
assert <%= spec.variable %>.created_at
|
|
19
|
+
end
|
|
20
|
+
<% fields.select(&:required?).each do |field| -%>
|
|
21
|
+
|
|
22
|
+
def test_requires_<%= field.column %>
|
|
23
|
+
<%= spec.variable %> = <%= class_name %>.new(sample.except(:<%= field.column %>))
|
|
24
|
+
|
|
25
|
+
refute_predicate <%= spec.variable %>, :valid?
|
|
26
|
+
assert_includes <%= spec.variable %>.errors[:<%= field.column %>], "is required"
|
|
27
|
+
end
|
|
28
|
+
<% end -%>
|
|
29
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What the API returns for a <%= class_name %>. Only listed attributes are exposed;
|
|
4
|
+
# their types (from <%= class_name %>'s fields) drive the generated TypeScript types.
|
|
5
|
+
class <%= class_name %>Serializer < ApplicationSerializer
|
|
6
|
+
attributes :id, <%= fields.map { |f| ":#{f.column}" }.join(", ") %>, :created_at, :updated_at
|
|
7
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# POST /api/uploads — step 1 of a direct upload (frontend/lib/upload.ts):
|
|
4
|
+
# validates the file's type and size and returns a short-lived URL the
|
|
5
|
+
# browser PUTs the file to. Save the returned signed_id on your record:
|
|
6
|
+
#
|
|
7
|
+
# product.update(image_key: GemStack::Storage.key_for_signed_id(input[:image]))
|
|
8
|
+
# GemStack::Storage.url(product.image_key) # signed download URL
|
|
9
|
+
#
|
|
10
|
+
# Allowed types and the size limit: config.storage (docs/storage.md).
|
|
11
|
+
class UploadsController < ApplicationController
|
|
12
|
+
<% if auth? -%>
|
|
13
|
+
before :require_login
|
|
14
|
+
<% else -%>
|
|
15
|
+
# Anyone can upload until you add authentication (gemstack add auth), then:
|
|
16
|
+
# before :require_login
|
|
17
|
+
<% end -%>
|
|
18
|
+
rate_limit to: 60, within: 3600, only: :create if respond_to?(:rate_limit)
|
|
19
|
+
|
|
20
|
+
accepts :create do
|
|
21
|
+
required :filename, :string, max_length: 255
|
|
22
|
+
required :content_type, :string, max_length: 100
|
|
23
|
+
required :byte_size, :integer, gt: 0
|
|
24
|
+
end
|
|
25
|
+
returns :create, UploadSerializer
|
|
26
|
+
|
|
27
|
+
def create
|
|
28
|
+
upload = GemStack::Storage.direct_upload(**input.slice(:filename, :content_type, :byte_size))
|
|
29
|
+
result = UploadSerializer::Result.new(signed_id: upload[:signed_id], url: upload[:url],
|
|
30
|
+
http_method: upload[:method], headers: upload[:headers])
|
|
31
|
+
render result, status: :created, serializer: UploadSerializer
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Where and how the browser sends the file (see UploadsController).
|
|
4
|
+
class UploadSerializer < ApplicationSerializer
|
|
5
|
+
Result = Data.define(:signed_id, :url, :http_method, :headers)
|
|
6
|
+
|
|
7
|
+
attribute :signed_id, :string
|
|
8
|
+
attribute :url, :string
|
|
9
|
+
attribute(:method, :string) { |upload| upload.http_method }
|
|
10
|
+
attribute :headers, :json
|
|
11
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Direct uploads (generated by `gemstack add storage`): the file goes from the
|
|
4
|
+
// browser straight to storage (S3, or the Ruby disk endpoint in development).
|
|
5
|
+
//
|
|
6
|
+
// const { signedId } = await uploadFile(file, { onProgress: (p) => setProgress(p) });
|
|
7
|
+
// await products.update(id, { image: signedId });
|
|
8
|
+
import { useMutation } from "@tanstack/react-query";
|
|
9
|
+
import { uploads } from "@/lib/api/generated";
|
|
10
|
+
|
|
11
|
+
export interface UploadResult {
|
|
12
|
+
/** Send this to the API to attach the file to a record. */
|
|
13
|
+
signedId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UploadOptions {
|
|
17
|
+
/** 0–1 while uploading. */
|
|
18
|
+
onProgress?: (fraction: number) => void;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function uploadFile(file: File, options: UploadOptions = {}): Promise<UploadResult> {
|
|
23
|
+
const upload = await uploads.create({
|
|
24
|
+
filename: file.name,
|
|
25
|
+
content_type: file.type || "application/octet-stream",
|
|
26
|
+
byte_size: file.size,
|
|
27
|
+
});
|
|
28
|
+
await send(upload.url, upload.method, upload.headers as Record<string, string>, file, options);
|
|
29
|
+
return { signedId: upload.signed_id };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// XMLHttpRequest rather than fetch: it reports upload progress.
|
|
33
|
+
function send(url: string, method: string, headers: Record<string, string>, file: File, options: UploadOptions) {
|
|
34
|
+
return new Promise<void>((resolve, reject) => {
|
|
35
|
+
const xhr = new XMLHttpRequest();
|
|
36
|
+
xhr.open(method, url);
|
|
37
|
+
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
|
|
38
|
+
xhr.upload.onprogress = (event) => {
|
|
39
|
+
if (event.lengthComputable) options.onProgress?.(event.loaded / event.total);
|
|
40
|
+
};
|
|
41
|
+
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`Upload failed (HTTP ${xhr.status})`)));
|
|
42
|
+
xhr.onerror = () => reject(new Error("Upload failed: network error"));
|
|
43
|
+
xhr.onabort = () => reject(new DOMException("Upload aborted", "AbortError"));
|
|
44
|
+
options.signal?.addEventListener("abort", () => xhr.abort());
|
|
45
|
+
xhr.send(file);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** uploadFile as a TanStack Query mutation: const upload = useUpload(); upload.mutateAsync(file). */
|
|
50
|
+
export function useUpload(options: Omit<UploadOptions, "signal"> = {}) {
|
|
51
|
+
return useMutation({ mutationFn: (file: File) => uploadFile(file, options) });
|
|
52
|
+
}
|