ruact 0.0.12 → 0.0.14
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 +59 -1
- data/README.md +4 -4
- data/lib/generators/ruact/install/install_generator.rb +418 -128
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
- data/lib/generators/ruact/install/templates/package.json.tt +4 -4
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
- data/lib/generators/ruact/layout/layout_generator.rb +52 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
- data/lib/ruact/configuration.rb +71 -17
- data/lib/ruact/controller/document_rendering.rb +72 -16
- data/lib/ruact/controller/page_rendering.rb +134 -0
- data/lib/ruact/controller/pages.rb +116 -0
- data/lib/ruact/controller.rb +78 -11
- data/lib/ruact/doctor.rb +233 -25
- data/lib/ruact/layout_source.rb +29 -7
- data/lib/ruact/navigation_boundary.rb +240 -0
- data/lib/ruact/railtie.rb +30 -0
- data/lib/ruact/routing.rb +24 -6
- data/lib/ruact/server.rb +10 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +158 -1
- data/lib/ruact/views/layouts/ruact.html.erb +32 -0
- data/lib/ruact.rb +29 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
- metadata +8 -2
|
@@ -15,7 +15,10 @@ truth" below) instead of guessing.
|
|
|
15
15
|
|
|
16
16
|
## Mental model
|
|
17
17
|
|
|
18
|
-
- A page is
|
|
18
|
+
- A page is an action rendering a `.html.erb` in a controller with `include Ruact::Controller`
|
|
19
|
+
(install adds it to none — `--app` puts it on ApplicationController; `ruact:scaffold`
|
|
20
|
+
adds it; `ruact_pages only:` narrows it). The page's renderer owns navigation:
|
|
21
|
+
going between a ruact page and a plain Rails/Turbo page is a full page load.
|
|
19
22
|
- Interactive components live in `app/javascript/components/` as `"use client"`
|
|
20
23
|
files, mounted from ERB with a PascalCase self-closing tag:
|
|
21
24
|
`<LikeButton postId={@post.id} />`.
|
|
@@ -66,10 +69,9 @@ object is instantiated.
|
|
|
66
69
|
|
|
67
70
|
## Ground truth — read the generated file
|
|
68
71
|
|
|
69
|
-
`app/javascript/.ruact/server-functions.ts`
|
|
70
|
-
table
|
|
71
|
-
|
|
72
|
-
Regenerate it after changing routes or queries:
|
|
72
|
+
`app/javascript/.ruact/server-functions.ts` (gitignored, regenerated from the
|
|
73
|
+
route table) is the authoritative list of every accessor and its typed params —
|
|
74
|
+
READ IT instead of simulating the name generation. Regenerate after routes/queries change:
|
|
73
75
|
|
|
74
76
|
bin/rails ruact:server_functions:generate
|
|
75
77
|
|
|
@@ -92,12 +94,12 @@ Regenerate it after changing routes or queries:
|
|
|
92
94
|
(a Flight stream for client-side navigation, an HTML page otherwise). You
|
|
93
95
|
cannot infer the response shape from the controller body alone — the
|
|
94
96
|
caller picks it.
|
|
95
|
-
4. **`ruact_errors` requires fall-through.** On
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
errors
|
|
97
|
+
4. **`ruact_errors` requires fall-through.** On a function call's failed-save
|
|
98
|
+
branch, call `ruact_errors(@post)` and let the action END — the implicit
|
|
99
|
+
render injects `errors: { attribute: [messages] }` into the JSON (an explicit
|
|
100
|
+
`render` opts out). On a page form, `ruact_errors(@post)` then
|
|
101
|
+
`render :new, status: :unprocessable_entity` re-renders the page through ruact
|
|
102
|
+
(view: `errors={ruact_errors}`); `redirect_to` instead carries them via flash.
|
|
101
103
|
5. **Accessor names are derived, not declared.** `posts#create` → `createPost`,
|
|
102
104
|
`posts#publish_all` → `publishAllPosts`; query methods camelCase the same
|
|
103
105
|
way (`search_users` → `searchUsers`). Collisions fail loudly at boot;
|
|
@@ -146,8 +148,7 @@ duration, or a deliberate `expires_in: nil` for a non-expiring token.
|
|
|
146
148
|
`schema_version` field (currently `0`), do not treat it as a stable contract.
|
|
147
149
|
- `bin/rails ruact:server_functions:generate` — regenerates the TS module;
|
|
148
150
|
exits 1 on a naming collision or an invalid name.
|
|
149
|
-
- `bin/dev` — boots Rails AND Vite (both
|
|
150
|
-
components and writes the client manifest).
|
|
151
|
+
- `bin/dev` — boots Rails AND Vite (both required: Vite serves the client components).
|
|
151
152
|
- If your app has TypeScript tooling configured, `npx tsc --noEmit`
|
|
152
153
|
type-checks call sites against the generated accessor types (a fresh
|
|
153
154
|
install does not ship a tsconfig).
|
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
Ruact.configure do |config|
|
|
4
|
-
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
4
|
+
<% if whole_app? -%>
|
|
5
|
+
# Whole-app mode (`rails generate ruact:install --app`, or an app installed
|
|
6
|
+
# before island mode was the default): ApplicationController
|
|
7
|
+
# includes Ruact::Controller, so every action with an .html.erb template
|
|
8
|
+
# renders through ruact, into your own layout. That layout needs
|
|
9
|
+
# `<%%= ruact_head_assets %>` in <head> and `<%%= ruact_js_assets %>` next to a
|
|
10
|
+
# `<div id="root"></div>` (`rails ruact:doctor` checks).
|
|
11
11
|
config.layout = true
|
|
12
|
+
<% else -%>
|
|
13
|
+
# Island mode: nothing in your controllers changed. A page renders through
|
|
14
|
+
# ruact when its controller has `include Ruact::Controller` — narrow it to
|
|
15
|
+
# some actions with `ruact_pages only: %i[show]`.
|
|
16
|
+
#
|
|
17
|
+
# Ruact pages render through the layout ruact ships (layouts/ruact). It links
|
|
18
|
+
# the CSS your client components import, then the stylesheets below — yours
|
|
19
|
+
# load last, so they win ties — and it edits none of your layouts. What it
|
|
20
|
+
# does NOT bring is the rest of your own layout's <head> or your app's
|
|
21
|
+
# JavaScript; to own that document, copy it into your app with
|
|
22
|
+
# `rails generate ruact:layout`.
|
|
23
|
+
#
|
|
24
|
+
# Set to true to render through your app's own layout instead (it then needs
|
|
25
|
+
# `<%%= ruact_head_assets %>` in <head> and `<%%= ruact_js_assets %>` next to a
|
|
26
|
+
# `<div id="root"></div>`; `rails ruact:doctor` checks), or false for ruact's
|
|
27
|
+
# minimal built-in shell, which carries none of your stylesheets.
|
|
28
|
+
config.layout = "ruact"
|
|
29
|
+
|
|
30
|
+
# The app stylesheets ruact's layout links, as you would pass them to
|
|
31
|
+
# stylesheet_link_tag. [:app] under Propshaft is every stylesheet you have.
|
|
32
|
+
config.layout_stylesheets = <%= detected_layout_stylesheets %>
|
|
33
|
+
<% end -%>
|
|
12
34
|
|
|
13
35
|
# Path to the react-client-manifest.json generated by the Vite plugin.
|
|
14
36
|
# Defaults to Rails.root.join("public/react-client-manifest.json").
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "vite",
|
|
7
7
|
"build": "vite build"<% if shadcn? %>,
|
|
8
|
-
"build:css": "
|
|
8
|
+
"build:css": "<%= SHADCN_BUILD_CSS_SCRIPT %>"<% end %>
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"react": "^19.0.0",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@vitejs/plugin-react": "^4.3.4",
|
|
16
16
|
<% if shadcn? -%>
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
17
|
+
<% SHADCN_DEV_DEPENDENCIES.each do |name, version| -%>
|
|
18
|
+
"<%= name %>": "<%= version %>",
|
|
19
|
+
<% end -%>
|
|
20
20
|
<% end -%>
|
|
21
21
|
"vite": "^6.0.7"
|
|
22
22
|
}
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"noEmit": true,
|
|
10
10
|
"esModuleInterop": true,
|
|
11
11
|
"skipLibCheck": true,
|
|
12
|
+
// Vite's client types. Without them a client component importing a
|
|
13
|
+
// stylesheet for its side effect has no declaration to resolve against.
|
|
14
|
+
"types": ["vite/client"],
|
|
12
15
|
"baseUrl": ".",
|
|
13
16
|
"paths": {
|
|
14
17
|
"@/*": ["app/javascript/*"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "ruact"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module Generators
|
|
8
|
+
# Copies the layout ruact pages render into (`layouts/ruact`, shipped with
|
|
9
|
+
# the gem) into the app, so the app owns it.
|
|
10
|
+
#
|
|
11
|
+
# Nothing needs configuring afterwards: the gem's view path is APPENDED by
|
|
12
|
+
# the Railtie, behind the app's, so `app/views/layouts/ruact.html.erb` wins
|
|
13
|
+
# by view-path order the moment it exists. That is the point of the ejected
|
|
14
|
+
# copy — add the app's JavaScript, fonts, meta tags or anything else
|
|
15
|
+
# `config.layout_stylesheets` cannot express.
|
|
16
|
+
#
|
|
17
|
+
# The cost, said once here and again when the command runs: an ejected
|
|
18
|
+
# layout no longer changes when the gem does.
|
|
19
|
+
#
|
|
20
|
+
# Run: rails generate ruact:layout
|
|
21
|
+
class LayoutGenerator < Rails::Generators::Base
|
|
22
|
+
source_root Ruact.views_path
|
|
23
|
+
|
|
24
|
+
desc "Copies ruact's layout into app/views/layouts/ruact.html.erb so your app owns it"
|
|
25
|
+
|
|
26
|
+
DESTINATION = "app/views/layouts/ruact.html.erb"
|
|
27
|
+
|
|
28
|
+
# `copy_file` already refuses to overwrite silently: an existing file
|
|
29
|
+
# prompts, or is skipped/overwritten under `--skip`/`--force`, which is
|
|
30
|
+
# the Rails convention every other generator follows.
|
|
31
|
+
def copy_layout
|
|
32
|
+
copy_file "layouts/ruact.html.erb", DESTINATION
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Only on `generate` (not `destroy`), and phrased for what is true: the copy
|
|
36
|
+
# renders ruact pages while `config.layout` is "ruact" — under `true` or
|
|
37
|
+
# `false` it is not used at all.
|
|
38
|
+
def explain
|
|
39
|
+
return unless behavior == :invoke
|
|
40
|
+
|
|
41
|
+
say ""
|
|
42
|
+
say " #{DESTINATION} renders ruact pages in place of the one ruact ships,"
|
|
43
|
+
say " as long as config.layout is \"ruact\". It will no longer change when you upgrade the gem —"
|
|
44
|
+
say " compare it with #{File.join(Ruact.views_path, 'layouts/ruact.html.erb')}"
|
|
45
|
+
say " after an upgrade. Keep `<%= ruact_head_assets %>` in <head>, and"
|
|
46
|
+
say " `<div id=\"root\"></div>` with `<%= ruact_js_assets %>` in <body>:"
|
|
47
|
+
say " `rails ruact:doctor` checks all three."
|
|
48
|
+
say ""
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -107,7 +107,7 @@ module Ruact
|
|
|
107
107
|
|
|
108
108
|
SUPPORTED_TYPES = TYPE_MAP.keys.freeze
|
|
109
109
|
|
|
110
|
-
# Column types the
|
|
110
|
+
# Column types the search query's case-insensitive LIKE scope spans —
|
|
111
111
|
# matching numeric/date/boolean columns by substring is meaningless.
|
|
112
112
|
SEARCHABLE_COLUMN_TYPES = %w[string text].freeze
|
|
113
113
|
|
|
@@ -121,7 +121,7 @@ module Ruact
|
|
|
121
121
|
REFERENCE_OPTIONS_LIMIT = 100
|
|
122
122
|
|
|
123
123
|
# Documentation anchor referenced by the unknown-type error message (AC4).
|
|
124
|
-
DOCS_POINTER = "https://
|
|
124
|
+
DOCS_POINTER = "https://ruact.dev/docs/api/scaffold.html#attribute-types"
|
|
125
125
|
|
|
126
126
|
# Story 10.5 (AC1, AC2, AC4) — the shadcn/ui dependency PRE-FLIGHT: detect
|
|
127
127
|
# the host's shadcn state (complete / missing / partial) and either proceed
|
|
@@ -191,7 +191,7 @@ module Ruact
|
|
|
191
191
|
end
|
|
192
192
|
|
|
193
193
|
# AC5 — the client-driven read path. Emits the resource query
|
|
194
|
-
# (`<Plural>Query < ApplicationQuery` with a `
|
|
194
|
+
# (`<Plural>Query < ApplicationQuery` with a `search_<plural>(q:)` method) in BOTH
|
|
195
195
|
# `.tsx` and `.jsx` modes (the query is server-side Ruby; the language flag
|
|
196
196
|
# only governs the React component). The `ApplicationQuery` base is created
|
|
197
197
|
# idempotently — `ruact:install` does NOT ship it, and a second scaffold in
|
|
@@ -205,13 +205,13 @@ module Ruact
|
|
|
205
205
|
template "queries/application_query.rb.tt", application_query
|
|
206
206
|
end
|
|
207
207
|
|
|
208
|
-
# AC5 — mount the resource query so its `
|
|
209
|
-
# GET route the codegen exports as `search
|
|
208
|
+
# AC5 — mount the resource query so its `search_<plural>` method becomes the
|
|
209
|
+
# named GET route the codegen exports as `search<Plural>` (consumed by `useQuery`).
|
|
210
210
|
# Idempotent on re-run: guard on the drawn `ruact_queries <Plural>Query`
|
|
211
211
|
# line first (sibling of {#add_resource_route}'s `resources :posts` guard).
|
|
212
212
|
def add_query_route
|
|
213
213
|
routes_file = Pathname(destination_root).join("config/routes.rb")
|
|
214
|
-
if routes_file.exist? && routes_file.read
|
|
214
|
+
if routes_file.exist? && query_already_mounted?(routes_file.read)
|
|
215
215
|
say_status "skip", "ruact_queries #{query_class_name} already routed", :yellow
|
|
216
216
|
return
|
|
217
217
|
end
|
|
@@ -406,17 +406,44 @@ module Ruact
|
|
|
406
406
|
|
|
407
407
|
# The read-side query class — PLURAL, mirroring the golden `PostsQuery`
|
|
408
408
|
# (file `posts_query.rb`) and Zeitwerk's path↔constant rule. Mounted via
|
|
409
|
-
# `ruact_queries <Plural>Query`; its
|
|
409
|
+
# `ruact_queries <Plural>Query`; its search method becomes `GET /q/searchPosts`.
|
|
410
410
|
def query_class_name
|
|
411
411
|
"#{class_name.pluralize}Query"
|
|
412
412
|
end
|
|
413
413
|
|
|
414
|
-
#
|
|
415
|
-
#
|
|
416
|
-
#
|
|
417
|
-
|
|
414
|
+
# True when a `ruact_queries` line already lists this exact class —
|
|
415
|
+
# alone or among others, ignoring a trailing comment and not mistaking
|
|
416
|
+
# `Legacy::PostsQuery` or `BlogPostsQuery` for `PostsQuery`.
|
|
417
|
+
def query_already_mounted?(routes)
|
|
418
|
+
class_ref = /(?<![:\w])#{Regexp.escape(query_class_name)}\b/
|
|
419
|
+
routes.each_line.any? do |line|
|
|
420
|
+
code = line.sub(/#.*/, "")
|
|
421
|
+
code.match?(/\A\s*ruact_queries\b/) && code.match?(class_ref)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# The query's search method, named after the resource (`search_posts`).
|
|
426
|
+
# Query names share ONE namespace — one `GET /q/<name>` route and one
|
|
427
|
+
# export of `@/.ruact/server-functions` per name — so a bare `search`
|
|
428
|
+
# was free for the first resource and broke the boot on the second
|
|
429
|
+
# (`ruact_query_search` drawn twice).
|
|
430
|
+
def query_search_method
|
|
431
|
+
"search_#{plural_table_name}"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# The accessor the codegen exports for {#query_search_method}
|
|
435
|
+
# (`searchPosts`); the List imports it under this name, unaliased.
|
|
418
436
|
def js_search_alias
|
|
419
|
-
|
|
437
|
+
Ruact::ServerFunctions::NameBridge.to_js_identifier(query_search_method)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# The List's collection prop, named after the resource (`posts`,
|
|
441
|
+
# `comments`, `blogPosts`) rather than fixed to the golden's `posts`.
|
|
442
|
+
# The List binds it to a fixed local (`initialRows`), so a model whose
|
|
443
|
+
# plural matches one of the List's own names (`Row` → `rows`) or one a
|
|
444
|
+
# module cannot declare (`Argument` → `arguments`) still compiles.
|
|
445
|
+
def js_collection_prop
|
|
446
|
+
plural_table_name.camelize(:lower)
|
|
420
447
|
end
|
|
421
448
|
|
|
422
449
|
# The columns the search `LIKE` scope spans — string/text only (a
|
|
@@ -20,7 +20,7 @@ module Ruact
|
|
|
20
20
|
# Documentation anchor for the shadcn dependency pre-flight: how to set
|
|
21
21
|
# up shadcn, and how to override the version-compat warning.
|
|
22
22
|
SHADCN_DOCS_POINTER =
|
|
23
|
-
"https://
|
|
23
|
+
"https://ruact.dev/docs/guides/shadcn-ui.html#shadcn-versions"
|
|
24
24
|
|
|
25
25
|
# The pre-flight body (the {ScaffoldGenerator#check_shadcn_setup} Thor
|
|
26
26
|
# command delegates here). Detect the host's shadcn state, surface the
|
|
@@ -141,9 +141,10 @@ module Ruact
|
|
|
141
141
|
<<~MSG.chomp
|
|
142
142
|
ruact:scaffold — shadcn/ui is not set up in this app yet.
|
|
143
143
|
The generated components import from @/components/ui/*, which does not exist.
|
|
144
|
-
Set up shadcn/ui first
|
|
144
|
+
Set up shadcn/ui first (--base radix: the components import Radix primitives,
|
|
145
|
+
and shadcn now defaults to Base UI), then re-run this generator:
|
|
145
146
|
|
|
146
|
-
npx shadcn@latest init
|
|
147
|
+
npx shadcn@latest init --base radix
|
|
147
148
|
#{shadcn_add_command(required_shadcn_components)}
|
|
148
149
|
|
|
149
150
|
No files were written (no partial state). Advanced: pass --skip-shadcn-check
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// small GENERATED client-side sort. There is NO table-engine dependency: the
|
|
17
17
|
// scaffold stays dep-free (no react-table runtime), matching the dep-free
|
|
18
18
|
// Form and the native date inputs. The collection arrives as a SERVER-RENDERED
|
|
19
|
-
// prop (
|
|
19
|
+
// prop (`<%= js_collection_prop %>`); there is no client query for the initial render. A query only
|
|
20
20
|
// enters when the *client* drives the read: the search box calls
|
|
21
21
|
// useQuery(<%= js_search_alias %>, { q }) and swaps in filtered rows as you type.
|
|
22
22
|
// Per-row delete drives a controlled <%= class_name %>DeleteDialog (DELETE
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// freshly scaffolded app will not resolve these until 10.5 lands; that is
|
|
31
31
|
// expected (the end-to-end live demo is Story 10.7).
|
|
32
32
|
import { useState } from "react";
|
|
33
|
-
import {
|
|
33
|
+
import { <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
|
|
34
34
|
import { <%= class_name %>DeleteDialog } from "./<%= class_name %>DeleteDialog";
|
|
35
35
|
import { Badge } from "@/components/ui/badge";
|
|
36
36
|
import { Button } from "@/components/ui/button";
|
|
@@ -52,11 +52,11 @@ import {
|
|
|
52
52
|
|
|
53
53
|
type <%= class_name %>Row = { <%= ts_row_fields %> };
|
|
54
54
|
|
|
55
|
-
// FR100 — opt-in call-site contract:
|
|
56
|
-
// `<<%= class_name %>List
|
|
55
|
+
// FR100 — opt-in call-site contract: `<%= js_collection_prop %>` is required. The index view passes
|
|
56
|
+
// `<<%= class_name %>List <%= js_collection_prop %>={rows} />` (satisfied); a call site that omits it
|
|
57
57
|
// fails at preprocess time, not as a silent `undefined` in the browser.
|
|
58
58
|
export const __ruactContract = {
|
|
59
|
-
props: {
|
|
59
|
+
props: { <%= js_collection_prop %>: "required" },
|
|
60
60
|
};
|
|
61
61
|
<% end -%>
|
|
62
62
|
|
|
@@ -208,9 +208,9 @@ function RowActions({ record, onDeleted }<% if typescript? %>: {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
export function <%= class_name %>List({
|
|
211
|
-
|
|
211
|
+
<%= js_collection_prop %>: initialRows = [],
|
|
212
212
|
emptyLabel = "No <%= plural_name %> yet — create one.",
|
|
213
|
-
}<% if typescript? %>: {
|
|
213
|
+
}<% if typescript? %>: { <%= js_collection_prop %>?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
|
|
214
214
|
const [q, setQ] = useState("");
|
|
215
215
|
const searching = q.trim().length > 0;
|
|
216
216
|
|
|
@@ -229,7 +229,7 @@ export function <%= class_name %>List({
|
|
|
229
229
|
// the box is idle and we fall back to the server-rendered rows.
|
|
230
230
|
const { data: searchData, loading: searchLoading } = useQuery<% if typescript? %><<%= class_name %>Row[]><% end %>(<%= js_search_alias %>, { q: q.trim() });
|
|
231
231
|
|
|
232
|
-
const source = searching ? searchData ?? [] :
|
|
232
|
+
const source = searching ? searchData ?? [] : initialRows;
|
|
233
233
|
const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
|
|
234
234
|
// Always sort a COPY — never mutate the prop/source array.
|
|
235
235
|
const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// <%= class_name %> list — a DESIGN-SYSTEM-AGNOSTIC table (Story 14.4 / FR103). The
|
|
9
9
|
// default scaffold ships plain, native HTML elements styled by the browser /
|
|
10
10
|
// Rails-default CSS — NO shadcn/ui, NO Tailwind, NO table-engine dependency. The
|
|
11
|
-
// collection arrives as a SERVER-RENDERED prop (
|
|
11
|
+
// collection arrives as a SERVER-RENDERED prop (`<%= js_collection_prop %>`); there is no client query
|
|
12
12
|
// for the initial render. A query only enters when the *client* drives the read:
|
|
13
13
|
// the search box calls useQuery(<%= js_search_alias %>, { q }) and swaps in filtered rows as
|
|
14
14
|
// you type. Per-row delete drives a controlled <%= class_name %>DeleteDialog (DELETE
|
|
@@ -18,17 +18,17 @@
|
|
|
18
18
|
// sort/pagination is Phase-3 territory. (The richer shadcn DataTable styling is
|
|
19
19
|
// the opt-in `--shadcn` path — Story 14.5.)
|
|
20
20
|
import { useState } from "react";
|
|
21
|
-
import {
|
|
21
|
+
import { <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
|
|
22
22
|
import { <%= class_name %>DeleteDialog } from "./<%= class_name %>DeleteDialog";
|
|
23
23
|
<% if typescript? -%>
|
|
24
24
|
|
|
25
25
|
type <%= class_name %>Row = { <%= ts_row_fields %> };
|
|
26
26
|
|
|
27
|
-
// FR100 — opt-in call-site contract:
|
|
28
|
-
// `<<%= class_name %>List
|
|
27
|
+
// FR100 — opt-in call-site contract: `<%= js_collection_prop %>` is required. The index view passes
|
|
28
|
+
// `<<%= class_name %>List <%= js_collection_prop %>={rows} />` (satisfied); a call site that omits it
|
|
29
29
|
// fails at preprocess time, not as a silent `undefined` in the browser.
|
|
30
30
|
export const __ruactContract = {
|
|
31
|
-
props: {
|
|
31
|
+
props: { <%= js_collection_prop %>: "required" },
|
|
32
32
|
};
|
|
33
33
|
<% end -%>
|
|
34
34
|
|
|
@@ -146,9 +146,9 @@ function RowActions({ record, onDeleted }<% if typescript? %>: {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
export function <%= class_name %>List({
|
|
149
|
-
|
|
149
|
+
<%= js_collection_prop %>: initialRows = [],
|
|
150
150
|
emptyLabel = "No <%= plural_name %> yet — create one.",
|
|
151
|
-
}<% if typescript? %>: {
|
|
151
|
+
}<% if typescript? %>: { <%= js_collection_prop %>?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
|
|
152
152
|
const [q, setQ] = useState("");
|
|
153
153
|
const searching = q.trim().length > 0;
|
|
154
154
|
|
|
@@ -167,7 +167,7 @@ export function <%= class_name %>List({
|
|
|
167
167
|
// the box is idle and we fall back to the server-rendered rows.
|
|
168
168
|
const { data: searchData, loading: searchLoading } = useQuery<% if typescript? %><<%= class_name %>Row[]><% end %>(<%= js_search_alias %>, { q: q.trim() });
|
|
169
169
|
|
|
170
|
-
const source = searching ? searchData ?? [] :
|
|
170
|
+
const source = searching ? searchData ?? [] : initialRows;
|
|
171
171
|
const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
|
|
172
172
|
// Always sort a COPY — never mutate the prop/source array.
|
|
173
173
|
const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
# <%= controller_class_name %>Controller — a complete CRUD on the v2 route-driven contract.
|
|
4
4
|
#
|
|
5
|
-
# `include Ruact::
|
|
6
|
-
# ApplicationController
|
|
7
|
-
#
|
|
5
|
+
# `include Ruact::Controller` makes this controller's pages ruact pages — the
|
|
6
|
+
# install no longer puts it on ApplicationController (Story 17.0g: island mode
|
|
7
|
+
# is the default; in whole-app mode, `ruact:install --app`, including it again
|
|
8
|
+
# here is a no-op). `include Ruact::Server` then gives it two behaviours:
|
|
8
9
|
#
|
|
9
10
|
# - GET actions (index/show/new/edit) need NO explicit `ruact_render` —
|
|
10
11
|
# Ruact::Controller#default_render activates the RSC pipeline implicitly
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
# NOTE: `include Ruact::Server` must come AFTER `protect_from_forgery` (inherited
|
|
23
24
|
# from ApplicationController) so the CSRF check precedes these actions.
|
|
24
25
|
class <%= controller_class_name %>Controller < ApplicationController
|
|
26
|
+
include Ruact::Controller
|
|
25
27
|
include Ruact::Server
|
|
26
28
|
|
|
27
29
|
# --- GET pages (rendered as RSC) -----------------------------------------
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Read side of the CRUD. Each public instance method becomes one named GET
|
|
4
4
|
# route when mounted with `ruact_queries <%= query_class_name %>`:
|
|
5
5
|
#
|
|
6
|
-
# GET /q
|
|
6
|
+
# GET /q/<%= js_search_alias %> → <%= query_class_name %>#<%= query_search_method %>(q:) → useQuery(<%= js_search_alias %>, { q }) (list search box)
|
|
7
7
|
#
|
|
8
8
|
# NOTE: there is deliberately NO whole-list query — the index list is
|
|
9
9
|
# server-rendered as props (see <%= controller_class_name %>Controller#index). A query is only the
|
|
@@ -14,7 +14,7 @@ class <%= query_class_name %> < ApplicationQuery
|
|
|
14
14
|
# is what justifies a query — the result depends on live client input, not on
|
|
15
15
|
# what the server already had. Rows are the SAME shape the index serializes, so
|
|
16
16
|
# the search results and the server-rendered props are interchangeable.
|
|
17
|
-
def
|
|
17
|
+
def <%= query_search_method %>(q:)
|
|
18
18
|
term = q.to_s.strip
|
|
19
19
|
scope =
|
|
20
20
|
<% if searchable_attributes.empty? -%>
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
Flight payload: no client query, no loading flash. The ivar is serialized to
|
|
4
4
|
plain row hashes here in the view (no as_json on the model). %>
|
|
5
5
|
<%% rows = @<%= plural_name %>.map { |<%= singular_name %>| <%= serialized_row(singular_name) %> } %>
|
|
6
|
-
<<%= class_name %>List
|
|
6
|
+
<<%= class_name %>List <%= js_collection_prop %>={rows} />
|
data/lib/ruact/configuration.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Ruact
|
|
|
9
9
|
# `Ruact::ConfigurationError` with the offending attribute, the caller's
|
|
10
10
|
# file:line, and the suggested fix. Re-calling `Ruact.configure` after boot
|
|
11
11
|
# replaces the configuration atomically and emits a `[ruact]` warning.
|
|
12
|
-
class Configuration
|
|
12
|
+
class Configuration # rubocop:disable Metrics/ClassLength -- an attribute list with a validator per attribute; Doctor and InstallGenerator carry the same disable
|
|
13
13
|
# The set of public attributes; new attributes added here automatically
|
|
14
14
|
# inherit the freeze contract via the `define_method` writer below.
|
|
15
15
|
ATTRIBUTES = %i[
|
|
@@ -25,6 +25,7 @@ module Ruact
|
|
|
25
25
|
signed_global_id_default_expires_in
|
|
26
26
|
shadcn_compatible_versions
|
|
27
27
|
layout
|
|
28
|
+
layout_stylesheets
|
|
28
29
|
].freeze
|
|
29
30
|
|
|
30
31
|
# @!attribute [r] manifest_path
|
|
@@ -72,7 +73,7 @@ module Ruact
|
|
|
72
73
|
# a stream-safety guarantee — Rack's multipart parser will still buffer
|
|
73
74
|
# bodies up to its own limits before the guard rejects. For very large
|
|
74
75
|
# uploads route through Active Storage Direct Upload or a presigned S3
|
|
75
|
-
# URL; see
|
|
76
|
+
# URL; see https://ruact.dev/docs/api/server-actions.html, "File uploads".
|
|
76
77
|
# @example Raise the limit to 25 MB
|
|
77
78
|
# Ruact.configure { |c| c.max_upload_bytes = 25 * 1024 * 1024 }
|
|
78
79
|
# @example Disable the gem-side guard (reverse proxy owns the cap)
|
|
@@ -131,11 +132,13 @@ module Ruact
|
|
|
131
132
|
# host `package.json`) that is NOT in this list, it emits a warning
|
|
132
133
|
# (never a hard stop) that the generated components may import from
|
|
133
134
|
# outdated `@/components/ui/*` paths. Must be a non-empty Array of
|
|
134
|
-
# Integers. Default `[1, 2]` (the majors tested at gem-release time
|
|
135
|
+
# Integers. Default `[1, 2, 4]` (the majors tested at gem-release time;
|
|
136
|
+
# 4 was run end to end on 2026-10-01: init --base radix, the full add
|
|
137
|
+
# list, scaffold --shadcn, form/table/dialog in a browser).
|
|
135
138
|
# A dev who has manually verified a newer major adds it here to
|
|
136
139
|
# suppress the warning — the documented "override" path.
|
|
137
140
|
# @example Allow shadcn v3 once you have verified it
|
|
138
|
-
# Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
|
|
141
|
+
# Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3, 4] }
|
|
139
142
|
#
|
|
140
143
|
# @!attribute [r] layout
|
|
141
144
|
# @return [Boolean, String] Which document wrapper a ruact page's HTML
|
|
@@ -144,17 +147,21 @@ module Ruact
|
|
|
144
147
|
# full-document render a browser gets on a normal navigation.
|
|
145
148
|
#
|
|
146
149
|
# - `false` (default) — render the gem's built-in minimal shell.
|
|
150
|
+
# - `"ruact"` — render through the layout the gem ships
|
|
151
|
+
# (`lib/ruact/views/layouts/ruact.html.erb`, Story 17.0b): CSRF, CSP,
|
|
152
|
+
# the client-component CSS, then the app stylesheets named in
|
|
153
|
+
# {#layout_stylesheets}. What `rails generate ruact:install` writes.
|
|
154
|
+
# It edits no layout of the app; an app that puts its own
|
|
155
|
+
# `app/views/layouts/ruact.html.erb` in place (`rails generate
|
|
156
|
+
# ruact:layout` copies the gem's) wins by view-path order.
|
|
147
157
|
# - `true` — render through the controller's normal Rails layout.
|
|
148
|
-
# -
|
|
158
|
+
# - another String — render through that named layout.
|
|
149
159
|
#
|
|
150
|
-
# The
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
# the
|
|
154
|
-
# the `false` default a ruact page renders with no app CSS at all
|
|
155
|
-
# which is why `rails generate ruact:install` writes `config.layout =
|
|
156
|
-
# true` into the generated initializer and adds `<%= ruact_js_assets %>`
|
|
157
|
-
# to your layout in the same run.
|
|
160
|
+
# The trade-off between the last two and `"ruact"`: the app's own layout
|
|
161
|
+
# brings its whole `<head>` (favicons, fonts, analytics, `<head>`-writing
|
|
162
|
+
# gems) but has to be wired by hand; the gem's layout needs no wiring but
|
|
163
|
+
# brings only the stylesheets. The built-in shell carries neither — under
|
|
164
|
+
# the `false` default a ruact page renders with no app CSS at all.
|
|
158
165
|
#
|
|
159
166
|
# **This setting is deliberately explicit — there is no auto-detection.**
|
|
160
167
|
# ruact used to try to infer whether your layout was ready by inspecting
|
|
@@ -173,10 +180,26 @@ module Ruact
|
|
|
173
180
|
# @note A ruact view is rendered in its own pass (it produces the component
|
|
174
181
|
# tree), so `content_for` declared inside the view does NOT reach the
|
|
175
182
|
# layout. Set document metadata from the controller instead.
|
|
176
|
-
# @example
|
|
177
|
-
# Ruact.configure { |c| c.layout = true }
|
|
178
|
-
# @example Use a dedicated layout for ruact pages only
|
|
183
|
+
# @example Render through the layout ruact ships (what ruact:install writes)
|
|
179
184
|
# Ruact.configure { |c| c.layout = "ruact" }
|
|
185
|
+
# @example Let your own application layout own the document
|
|
186
|
+
# Ruact.configure { |c| c.layout = true }
|
|
187
|
+
#
|
|
188
|
+
# @!attribute [r] layout_stylesheets
|
|
189
|
+
# @return [Array<Symbol, String>] The app stylesheets the gem's layout
|
|
190
|
+
# (`layouts/ruact`, used when `layout` is `"ruact"`) links, passed as-is
|
|
191
|
+
# to `stylesheet_link_tag`. They come AFTER the client-component CSS
|
|
192
|
+
# (`ruact_head_assets`), so the app's own CSS loads last and wins ties.
|
|
193
|
+
#
|
|
194
|
+
# Defaults to `[:app]`, what `rails new` 8.x puts in its layout: under
|
|
195
|
+
# Propshaft it expands to every stylesheet on the load path, including a
|
|
196
|
+
# Tailwind build. Under Sprockets `:app` means a file called `app.css`,
|
|
197
|
+
# which is why `rails generate ruact:install` writes `["application"]`
|
|
198
|
+
# there instead. `[]` links none. Anything more than a list of names —
|
|
199
|
+
# a media attribute, fonts, other `<head>` tags — is what ejecting the
|
|
200
|
+
# layout is for: `rails generate ruact:layout`.
|
|
201
|
+
# @example A Sprockets app
|
|
202
|
+
# Ruact.configure { |c| c.layout_stylesheets = ["application"] }
|
|
180
203
|
ATTRIBUTES.each do |attr|
|
|
181
204
|
attr_reader attr
|
|
182
205
|
|
|
@@ -229,8 +252,9 @@ module Ruact
|
|
|
229
252
|
@query_parent_controller = "ApplicationController"
|
|
230
253
|
@signed_global_id_default_purpose = nil
|
|
231
254
|
@signed_global_id_default_expires_in = nil
|
|
232
|
-
@shadcn_compatible_versions = [1, 2]
|
|
255
|
+
@shadcn_compatible_versions = [1, 2, 4]
|
|
233
256
|
@layout = false
|
|
257
|
+
@layout_stylesheets = [:app]
|
|
234
258
|
end
|
|
235
259
|
end
|
|
236
260
|
|
|
@@ -266,6 +290,10 @@ module Ruact
|
|
|
266
290
|
# reference, but a caller probing `frozen?` would see the right answer.
|
|
267
291
|
if value.is_a?(Proc)
|
|
268
292
|
value.freeze
|
|
293
|
+
elsif value.is_a?(Array)
|
|
294
|
+
# Story 17.0b — `layout_stylesheets` holds Strings; freezing only the
|
|
295
|
+
# Array would leave `Ruact.config.layout_stylesheets.first << "x"` open.
|
|
296
|
+
instance_variable_set("@#{attr}", value.map { |item| item.frozen? ? item : item.dup.freeze }.freeze)
|
|
269
297
|
else
|
|
270
298
|
instance_variable_set("@#{attr}", value.dup.freeze)
|
|
271
299
|
end
|
|
@@ -293,6 +321,7 @@ module Ruact
|
|
|
293
321
|
when :query_parent_controller then validate_query_parent_controller!(value)
|
|
294
322
|
when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
|
|
295
323
|
when :layout then validate_layout!(value)
|
|
324
|
+
when :layout_stylesheets then validate_layout_stylesheets!(value)
|
|
296
325
|
end
|
|
297
326
|
end
|
|
298
327
|
|
|
@@ -325,6 +354,31 @@ module Ruact
|
|
|
325
354
|
"false uses ruact's built-in shell."
|
|
326
355
|
end
|
|
327
356
|
|
|
357
|
+
# Story 17.0b — the arguments `layouts/ruact` passes to `stylesheet_link_tag`.
|
|
358
|
+
# Checked at boot because the layout splats them straight into a Rails
|
|
359
|
+
# helper, where a stray nil or Hash would surface as a first-render error
|
|
360
|
+
# instead of a legible configuration one.
|
|
361
|
+
#
|
|
362
|
+
# Propshaft reads `:app` / `:all` ONLY as the first item, and then ignores
|
|
363
|
+
# every other one (`case sources.first when :app then sources = …`): so
|
|
364
|
+
# `[:app, "theme"]` silently drops "theme", and `["reset", :app]` looks for
|
|
365
|
+
# a file named app.css and raises. Either is allowed only on its own.
|
|
366
|
+
def validate_layout_stylesheets!(value)
|
|
367
|
+
if value.is_a?(Array) && value.length > 1 && value.intersect?(%i[app all])
|
|
368
|
+
raise Ruact::ConfigurationError,
|
|
369
|
+
"Ruact::Configuration#layout_stylesheets: :app and :all stand alone — Propshaft reads " \
|
|
370
|
+
"them only as the whole list and drops anything next to them; got #{value.inspect}. " \
|
|
371
|
+
"Use [:app], or list the stylesheets by name."
|
|
372
|
+
end
|
|
373
|
+
return if value.is_a?(Array) && value.all? { |name| name.is_a?(Symbol) || (name.is_a?(String) && !name.empty?) }
|
|
374
|
+
|
|
375
|
+
raise Ruact::ConfigurationError,
|
|
376
|
+
"Ruact::Configuration#layout_stylesheets must be an Array of stylesheet names " \
|
|
377
|
+
"(Symbols or non-empty Strings, as you would pass to stylesheet_link_tag), " \
|
|
378
|
+
"e.g. [:app] or [\"application\"]; got #{value.inspect} (#{value.class.name}). " \
|
|
379
|
+
"Use [] to link none of your app's stylesheets."
|
|
380
|
+
end
|
|
381
|
+
|
|
328
382
|
def validate_max_upload_bytes!(value)
|
|
329
383
|
return if value.nil?
|
|
330
384
|
return if value.is_a?(Integer) && value >= 0
|