cafe_car 0.1.2 → 0.2.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/README.md +84 -2
- data/app/controllers/cafe_car/examples_controller.rb +0 -1
- data/app/javascript/cafe_car.js +28 -0
- data/app/views/application/_index.html.haml +2 -0
- data/app/views/application/_index_actions.html.haml +2 -0
- data/app/views/application/_nested_field.html.haml +32 -0
- data/app/views/application/_search.html.haml +6 -0
- data/app/views/cafe_car/examples/ui/_alert.html.haml +4 -4
- data/config/locales/en.yml +3 -3
- data/lib/cafe_car/controller/filtering.rb +10 -3
- data/lib/cafe_car/controller.rb +4 -3
- data/lib/cafe_car/engine.rb +44 -0
- data/lib/cafe_car/field_info.rb +3 -3
- data/lib/cafe_car/generators.rb +13 -0
- data/lib/cafe_car/helpers.rb +4 -0
- data/lib/cafe_car/query_builder.rb +1 -2
- data/lib/cafe_car/queryable.rb +19 -0
- data/lib/cafe_car/version.rb +1 -1
- data/lib/cafe_car.rb +4 -0
- data/lib/generators/cafe_car/policy/policy_generator.rb +6 -1
- data/lib/generators/cafe_car/resource/resource_generator.rb +4 -3
- metadata +8 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7349873b72f79f05fd45bdb30e1a474b0d89d0b8352172378f6cecd239cc4f56
|
|
4
|
+
data.tar.gz: 1c2dcb971ecb7426bd168add0ffd46bff5fff204600e6f125aaa853db983feea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d9aebfaa9fec6e15149003739dab554bcd1e3279c66ef040fba72656b8d916c411cac39e05589e08e3b95b28a77311fac6875068e9a6f527f73c8a16d43fbe7
|
|
7
|
+
data.tar.gz: 037c7c894cd5c161e3c3544195074888d71166a6504e5e3115cd660372ac4038d793a1e78ab65713e760a49d019ff32717b73c99eba59a62fe935234705adfde
|
data/README.md
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
|
-
|
|
1
|
+
<h1>
|
|
2
|
+
<img src="https://raw.githubusercontent.com/craft-concept/cafe_car/main/docs/images/logo.png"
|
|
3
|
+
alt="" width="40" valign="middle">
|
|
4
|
+
CafeCar
|
|
5
|
+
</h1>
|
|
2
6
|
|
|
3
7
|
[](https://github.com/craft-concept/cafe_car/actions/workflows/ci.yml)
|
|
4
8
|
[](https://rubygems.org/gems/cafe_car)
|
|
5
9
|
[](https://opensource.org/licenses/MIT)
|
|
6
10
|
|
|
11
|
+
> 🚀 **[Live demo →](https://cafe-car-demo-production.up.railway.app)** — click straight
|
|
12
|
+
> into a real auto-generated admin (clients, invoices, articles, users, notes). No signup;
|
|
13
|
+
> the data resets periodically.
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="https://cafe-car-demo-production.up.railway.app/admin/invoices">
|
|
17
|
+
<img src="https://raw.githubusercontent.com/craft-concept/cafe_car/main/docs/images/admin-invoices-index.png"
|
|
18
|
+
alt="CafeCar's auto-generated admin: an invoices index with sortable columns, currency formatting, association links, sender avatars, and pagination — all rendered straight from the model with no view code."
|
|
19
|
+
width="900">
|
|
20
|
+
</a>
|
|
21
|
+
</p>
|
|
22
|
+
<p align="center">
|
|
23
|
+
<em>A complete admin index — sortable columns, formatted values, association links, and
|
|
24
|
+
pagination — auto-generated from a model with one line of controller code.
|
|
25
|
+
<a href="https://cafe-car-demo-production.up.railway.app">Try the live demo →</a></em>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
7
28
|
CafeCar is a Rails engine that extends the MVC "view" layer to provide automatic
|
|
8
29
|
CRUD UI generation with sensible defaults. Its philosophy is rooted in the idea
|
|
9
30
|
that Rails should render _something_ that represents the CRUD operations of your
|
|
@@ -23,6 +44,10 @@ an application-wide or model-specific basis.
|
|
|
23
44
|
- 📊 **Smart presenters** - Automatic type-aware display of your data
|
|
24
45
|
- 🔍 **Advanced filtering** - Range queries, comparison operators, and
|
|
25
46
|
association filters
|
|
47
|
+
- 🔎 **Keyword search** - Turnkey search box on every index, matching across a
|
|
48
|
+
model's text columns with zero per-model setup
|
|
49
|
+
- ⬇️ **CSV export** - One-click "Download CSV" of the current filtered, sorted
|
|
50
|
+
view on any index
|
|
26
51
|
- 📄 **Pagination & sorting** - Kaminari integration with sortable columns
|
|
27
52
|
- ⚡ **Hotwire ready** - Turbo Streams support out of the box
|
|
28
53
|
- 📝 **Intelligent forms** - Auto-generated forms with smart field detection
|
|
@@ -86,7 +111,7 @@ $ rails db:migrate
|
|
|
86
111
|
$ rails server
|
|
87
112
|
```
|
|
88
113
|
|
|
89
|
-
Navigate to `/products` and you'll see a
|
|
114
|
+
Navigate to `/products` and you'll see a working CRUD interface.
|
|
90
115
|
|
|
91
116
|
### Manual Setup
|
|
92
117
|
|
|
@@ -344,6 +369,18 @@ end
|
|
|
344
369
|
|
|
345
370
|
CafeCar provides an enhanced form builder with smart field detection.
|
|
346
371
|
|
|
372
|
+
<p align="center">
|
|
373
|
+
<a href="https://cafe-car-demo-production.up.railway.app/admin/invoices/new">
|
|
374
|
+
<img src="https://raw.githubusercontent.com/craft-concept/cafe_car/main/docs/images/admin-invoice-form.png"
|
|
375
|
+
alt="CafeCar's auto-generated new-invoice form with a client association select, typed inputs, and nested has_many line items (add/remove rows)."
|
|
376
|
+
width="760">
|
|
377
|
+
</a>
|
|
378
|
+
</p>
|
|
379
|
+
<p align="center">
|
|
380
|
+
<em>An auto-generated form with a typed date field, an association select, and nested
|
|
381
|
+
<code>has_many</code> line items — add and remove rows inline.</em>
|
|
382
|
+
</p>
|
|
383
|
+
|
|
347
384
|
**Basic forms:**
|
|
348
385
|
|
|
349
386
|
```erb
|
|
@@ -413,6 +450,51 @@ CafeCar provides advanced filtering with minimal configuration.
|
|
|
413
450
|
/products?sort=category,-price # Multiple columns
|
|
414
451
|
```
|
|
415
452
|
|
|
453
|
+
**Keyword search:**
|
|
454
|
+
|
|
455
|
+
Every index ships with a search box — no configuration required. The `q`
|
|
456
|
+
parameter matches the term across the model's string/text columns
|
|
457
|
+
(case-insensitive, database-portable), and composes with the filters and sort
|
|
458
|
+
above:
|
|
459
|
+
|
|
460
|
+
```
|
|
461
|
+
/products?q=widget
|
|
462
|
+
/products?q=widget&category=tools&sort=-price
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Define a `search` scope on the model to override the default with your own
|
|
466
|
+
logic (scoped columns, full-text, etc.):
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
class Product < ApplicationRecord
|
|
470
|
+
scope :search, ->(term) { where("sku = ?", term) }
|
|
471
|
+
end
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Columns hidden by Rails' parameter filter (passwords, tokens) are never
|
|
475
|
+
searched.
|
|
476
|
+
|
|
477
|
+
**CSV export:**
|
|
478
|
+
|
|
479
|
+
Every index also renders as CSV — append `.csv` or click the "Download CSV"
|
|
480
|
+
link. The export honors the current filters and sort and includes matching
|
|
481
|
+
records across the whole result set (not just the page on screen). Columns
|
|
482
|
+
mirror the JSON index — the same filtered attribute set — so protected columns
|
|
483
|
+
(passwords, tokens) and association foreign keys never appear in the file:
|
|
484
|
+
|
|
485
|
+
```
|
|
486
|
+
/products.csv?category=tools&sort=-price
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
To keep a large export from materializing an unbounded result set in memory, the
|
|
490
|
+
output is capped at `CafeCar.csv_export_row_limit` rows (default `10_000`). When a
|
|
491
|
+
download is truncated, CafeCar sets an `X-CafeCar-Truncated: true` response header
|
|
492
|
+
and logs a warning. Raise the limit in an initializer if your tables are larger:
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
CafeCar.csv_export_row_limit = 50_000
|
|
496
|
+
```
|
|
497
|
+
|
|
416
498
|
**In models:**
|
|
417
499
|
|
|
418
500
|
```ruby
|
data/app/javascript/cafe_car.js
CHANGED
|
@@ -33,6 +33,34 @@ addEventListener("keydown", event => {
|
|
|
33
33
|
}
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
+
// Nested-attributes forms: add/remove repeatable `fields_for` rows. The add
|
|
37
|
+
// button clones the <template> (swapping the placeholder index for a unique
|
|
38
|
+
// one); the remove button drops unsaved rows from the DOM, or marks persisted
|
|
39
|
+
// rows for destruction by flipping their `_destroy` hidden field to "1".
|
|
40
|
+
addEventListener("click", event => {
|
|
41
|
+
let add = event.target.closest("[data-nested-add]")
|
|
42
|
+
if (add) {
|
|
43
|
+
let wrapper = add.closest("[data-nested-wrapper]")
|
|
44
|
+
let template = wrapper.querySelector("template[data-nested-template]")
|
|
45
|
+
let container = wrapper.querySelector("[data-nested-container]")
|
|
46
|
+
let html = template.innerHTML.replaceAll("CAFE_CAR_NEW_RECORD", Date.now())
|
|
47
|
+
container.insertAdjacentHTML("beforeend", html)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let remove = event.target.closest("[data-nested-remove]")
|
|
52
|
+
if (remove) {
|
|
53
|
+
let item = remove.closest("[data-nested-item]")
|
|
54
|
+
let destroy = item.querySelector("input[name*='[_destroy]']")
|
|
55
|
+
if (destroy) {
|
|
56
|
+
destroy.value = "1"
|
|
57
|
+
item.hidden = true
|
|
58
|
+
} else {
|
|
59
|
+
item.remove()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
36
64
|
function animationEnd({ target }) {
|
|
37
65
|
if (target.matches(".remove")) target.remove()
|
|
38
66
|
else if (target.matches(".popup")) {
|
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
= icon(:view_grid)
|
|
4
4
|
= Button href: view_url(:table), tip: "Table View" do
|
|
5
5
|
= icon(:table_rows)
|
|
6
|
+
= Button href: csv_url, tip: "Download CSV" do
|
|
7
|
+
= icon(:download)
|
|
6
8
|
|
|
7
9
|
= link(model.build).new(class: ui.Button(:primary).class_name, params: dot_params) { icon(:plus) + _1}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
- reflection = field.info.reflection
|
|
2
|
+
- if reflection&.collection?
|
|
3
|
+
- new_object = reflection.klass.new
|
|
4
|
+
- nested_opts = field.form.model.nested_attributes_options[field.method] || {}
|
|
5
|
+
- allow_destroy = nested_opts[:allow_destroy]
|
|
6
|
+
- plural = reflection.klass.model_name.plural
|
|
7
|
+
- item_partial = partial?("#{plural}/fields") ? "#{plural}/fields" : "fields"
|
|
8
|
+
= field.wrapper do
|
|
9
|
+
= field.label
|
|
10
|
+
= field.error
|
|
11
|
+
%div{data: {nested_wrapper: ""}}
|
|
12
|
+
%div{data: {nested_container: ""}}
|
|
13
|
+
= field.form.fields_for field.method do |f|
|
|
14
|
+
%div{data: {nested_item: ""}}
|
|
15
|
+
= render item_partial, f: f
|
|
16
|
+
- if allow_destroy
|
|
17
|
+
= f.hidden_field :_destroy, value: "0"
|
|
18
|
+
%button{type: "button", data: {nested_remove: ""}} Remove
|
|
19
|
+
%template{data: {nested_template: ""}}
|
|
20
|
+
%div{data: {nested_item: ""}}
|
|
21
|
+
= field.form.fields_for field.method, new_object, child_index: "CAFE_CAR_NEW_RECORD" do |f|
|
|
22
|
+
= render item_partial, f: f
|
|
23
|
+
- if allow_destroy
|
|
24
|
+
= f.hidden_field :_destroy, value: "0"
|
|
25
|
+
%button{type: "button", data: {nested_remove: ""}} Remove
|
|
26
|
+
%button{type: "button", data: {nested_add: ""}}= "+ #{field.info.label}"
|
|
27
|
+
- else
|
|
28
|
+
= field.wrapper do
|
|
29
|
+
= field.label
|
|
30
|
+
= field.error
|
|
31
|
+
= field.input
|
|
32
|
+
= field.hint
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
= form_with url: "", method: :get, class: "search" do |f|
|
|
2
|
+
- dot_params.each do |key, value|
|
|
3
|
+
= hidden_field_tag key, value
|
|
4
|
+
= hidden_field_tag :sort, params[:sort] if params[:sort].present?
|
|
5
|
+
= f.search_field :q, value: search_term, placeholder: t(:search, scope: :helpers, default: "Search")
|
|
6
|
+
= f.button "Search", name: nil
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
= Alert :success, "Success alert.
|
|
2
|
-
= Alert :info, "Info alert.
|
|
3
|
-
= Alert :error, "Error alert.
|
|
4
|
-
= Alert :warning, "Warning alert.
|
|
1
|
+
= Alert :success, "Success alert. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
|
|
2
|
+
= Alert :info, "Info alert. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
|
|
3
|
+
= Alert :error, "Error alert. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."
|
|
4
|
+
= Alert :warning, "Warning alert. Excepteur sint occaecat cupidatat non proident."
|
data/config/locales/en.yml
CHANGED
|
@@ -48,9 +48,9 @@ en:
|
|
|
48
48
|
published_at: Published
|
|
49
49
|
|
|
50
50
|
flashes:
|
|
51
|
-
create_html: "%{Model} created
|
|
52
|
-
update_html: "%{Model} updated
|
|
53
|
-
destroy_html: "%{Model} deleted
|
|
51
|
+
create_html: "%{Model} created."
|
|
52
|
+
update_html: "%{Model} updated."
|
|
53
|
+
destroy_html: "%{Model} deleted."
|
|
54
54
|
|
|
55
55
|
activerecord:
|
|
56
56
|
models:
|
|
@@ -2,19 +2,26 @@ module CafeCar::Controller::Filtering
|
|
|
2
2
|
extend ActiveSupport::Concern
|
|
3
3
|
|
|
4
4
|
included do
|
|
5
|
-
helper_method :parsed_params, :dot_params, :filtered
|
|
5
|
+
helper_method :parsed_params, :dot_params, :filtered?, :search_term
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
private
|
|
9
9
|
|
|
10
10
|
def filtered(scope)
|
|
11
|
-
scope.query(parsed_params[""])
|
|
11
|
+
scope.query([ parsed_params[""], search_term ].compact_blank)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def filtered?
|
|
15
|
-
parsed_params[""].present?
|
|
15
|
+
parsed_params[""].present? || search_term.present?
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Keyword term from the index search box. Read raw (not through ParamParser) so a
|
|
19
|
+
# term keeps its literal text, then funneled into the query DSL as a bare String
|
|
20
|
+
# that routes to `QueryBuilder#search!` alongside the dot-filters. Only a String
|
|
21
|
+
# counts — a crafted `?q[x]=y` (Hash) or `?q[]=a` (Array) is ignored, not searched
|
|
22
|
+
# (otherwise the non-String would reach the query DSL and raise a 500).
|
|
23
|
+
def search_term = (params[:q] if params[:q].is_a?(String)).presence
|
|
24
|
+
|
|
18
25
|
def dot_params
|
|
19
26
|
request.params.slice(*request.params.keys.grep(/^\./))
|
|
20
27
|
end
|
data/lib/cafe_car/controller.rb
CHANGED
|
@@ -32,7 +32,7 @@ module CafeCar
|
|
|
32
32
|
|
|
33
33
|
append_cafe_car_views
|
|
34
34
|
|
|
35
|
-
respond_to :json, :html, :turbo_stream
|
|
35
|
+
respond_to :json, :html, :turbo_stream, :csv
|
|
36
36
|
|
|
37
37
|
before_action :set_current_attributes
|
|
38
38
|
|
|
@@ -41,6 +41,8 @@ module CafeCar
|
|
|
41
41
|
before_action :find_objects, only: _only.(%i[index])
|
|
42
42
|
before_action :assign_attributes, only: _only.(%i[create update])
|
|
43
43
|
before_action :authorize!
|
|
44
|
+
|
|
45
|
+
after_action :verify_authorized, :verify_policy_scoped
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
def append_cafe_car_views
|
|
@@ -77,8 +79,6 @@ module CafeCar
|
|
|
77
79
|
|
|
78
80
|
helper Helpers
|
|
79
81
|
delegate :present, :href_for, :namespace, to: :helpers
|
|
80
|
-
|
|
81
|
-
after_action :verify_authorized, :verify_policy_scoped
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
def index = respond_with objects
|
|
@@ -122,6 +122,7 @@ module CafeCar
|
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
def paginated(scope, page: params[:page], per: params[:per])
|
|
125
|
+
return scope if request.format.csv? # CSV exports the full filtered+sorted set
|
|
125
126
|
scope.page(page).per(per)
|
|
126
127
|
end
|
|
127
128
|
|
data/lib/cafe_car/engine.rb
CHANGED
|
@@ -79,6 +79,50 @@ module CafeCar
|
|
|
79
79
|
config.responders.flash_keys = [ :success, :error ]
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
initializer "cafe_car.csv_renderer" do
|
|
83
|
+
require "csv"
|
|
84
|
+
|
|
85
|
+
# CSV export of an index's filtered+sorted collection. Columns mirror the
|
|
86
|
+
# JSON renderer's policy-respecting basis (`[:id] | displayable_attributes`)
|
|
87
|
+
# so exports never leak attributes the policy hides, narrowed to scalar DB
|
|
88
|
+
# columns. Associations are out of scope for v1 (only scalar values).
|
|
89
|
+
ActionController::Renderers.add :csv do |collection, options|
|
|
90
|
+
klass = collection.respond_to?(:klass) ? collection.klass : collection.class
|
|
91
|
+
basis = options[:only] || [ :id ] | policy(klass.new).displayable_attributes
|
|
92
|
+
columns = basis & klass.column_names.map(&:to_sym)
|
|
93
|
+
text = columns.select { klass.columns_hash[_1.to_s]&.type.in?(%i[string text]) }
|
|
94
|
+
|
|
95
|
+
# Neutralize CSV formula injection: a text value a spreadsheet would treat as
|
|
96
|
+
# a formula (leading = + - @, tab, CR) is prefixed with a quote. Guard only
|
|
97
|
+
# text columns so numeric values (e.g. negative amounts) aren't mangled.
|
|
98
|
+
cell = ->(col, value) do
|
|
99
|
+
text.include?(col) && value.to_s.match?(/\A[=+\-@\t\r]/) ? "'#{value}" : value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Bound the export: cap rows so a huge table can't materialize unbounded
|
|
103
|
+
# in memory. We deliberately limit (not `find_each`) to preserve the
|
|
104
|
+
# filtered+sorted order the export exists to mirror. A truncated export
|
|
105
|
+
# is signalled out-of-band so the data columns stay aligned.
|
|
106
|
+
cap = CafeCar.csv_export_row_limit
|
|
107
|
+
scope = collection.respond_to?(:limit) ? collection.limit(cap) : Array(collection).first(cap)
|
|
108
|
+
rows = 0
|
|
109
|
+
|
|
110
|
+
data = CSV.generate do |csv|
|
|
111
|
+
csv << columns.map { klass.human_attribute_name(_1) }
|
|
112
|
+
scope.each { |record| csv << columns.map { cell.(_1, record.public_send(_1)) }; rows += 1 }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if rows == cap
|
|
116
|
+
Rails.logger.warn("CafeCar CSV export of #{klass.name} truncated at #{cap} rows")
|
|
117
|
+
headers["X-CafeCar-Truncated"] = "true"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
self.content_type ||= Mime[:csv]
|
|
121
|
+
headers["Content-Disposition"] ||= %(attachment; filename="#{controller_name}.csv")
|
|
122
|
+
data
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
82
126
|
initializer "cafe_car.field_with_errors" do
|
|
83
127
|
ActionView::Base.field_error_proc = proc { _1.html_safe }
|
|
84
128
|
end
|
data/lib/cafe_car/field_info.rb
CHANGED
|
@@ -59,7 +59,7 @@ module CafeCar
|
|
|
59
59
|
|
|
60
60
|
def nested_attributes_type
|
|
61
61
|
key = @method.to_s.chomp("_attributes").to_sym
|
|
62
|
-
model.nested_attributes_options.key?(key)
|
|
62
|
+
:nested if model.nested_attributes_options.key?(key)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def polymorphic_methods = [ reflection.foreign_type, reflection.foreign_key ]
|
|
@@ -85,8 +85,8 @@ module CafeCar
|
|
|
85
85
|
|
|
86
86
|
def type
|
|
87
87
|
return if @method.nil?
|
|
88
|
-
@type ||= reflection_type || attribute_type || digest_type ||
|
|
89
|
-
|
|
88
|
+
@type ||= nested_attributes_type || reflection_type || attribute_type || digest_type ||
|
|
89
|
+
attachment_type || default_type ||
|
|
90
90
|
raise(NoMethodError.new "Can't find attribute :#{@method} on #{model_name}", @method)
|
|
91
91
|
end
|
|
92
92
|
|
data/lib/cafe_car/generators.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "rails/generators/active_record/migration"
|
|
2
2
|
require "rails/generators/bundle_helper"
|
|
3
|
+
require "shellwords"
|
|
3
4
|
|
|
4
5
|
module CafeCar::Generators
|
|
5
6
|
extend ActiveSupport::Concern
|
|
@@ -8,6 +9,18 @@ module CafeCar::Generators
|
|
|
8
9
|
|
|
9
10
|
private
|
|
10
11
|
|
|
12
|
+
# Delegate to another generator inline, anchored to THIS generator's
|
|
13
|
+
# destination_root. Rails' built-in `generate` action recomputes the
|
|
14
|
+
# destination from Rails::Command.root, which leaks writes into the engine
|
|
15
|
+
# repo (when a contributor runs the generator there) or escapes the test
|
|
16
|
+
# destination. Invoking the generator directly keeps every sub-generator
|
|
17
|
+
# writing where the parent does.
|
|
18
|
+
def generate(what, *args)
|
|
19
|
+
args.extract_options! # drop trailing option hashes (e.g. inline:); always inline here
|
|
20
|
+
what, *args = Shellwords.split("#{what} #{args.map(&:to_s).join(' ')}")
|
|
21
|
+
Rails::Generators.invoke(what, args, behavior: behavior, destination_root: destination_root)
|
|
22
|
+
end
|
|
23
|
+
|
|
11
24
|
def migration(name, ...) = migration_template("#{name}.rb", "db/migrate/#{name}.rb", ...)
|
|
12
25
|
|
|
13
26
|
def model(name, ...)
|
data/lib/cafe_car/helpers.rb
CHANGED
|
@@ -77,6 +77,10 @@ module CafeCar
|
|
|
77
77
|
url_for(params)
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Current index URL as `.csv`, carrying the on-screen filter + sort params so
|
|
81
|
+
# the export matches the view. Pagination is dropped (CSV exports the full set).
|
|
82
|
+
def csv_url = url_for(request.params.except("page", "per").merge(format: :csv))
|
|
83
|
+
|
|
80
84
|
def context(name = nil, &)
|
|
81
85
|
@context ||= []
|
|
82
86
|
|
|
@@ -154,9 +154,8 @@ module CafeCar
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def search!(term)
|
|
157
|
-
@scope.search!(term) if @scope.respond_to?(:search!)
|
|
158
157
|
@scope.query!("body~": term) if @scope < ::ActionText::RichText
|
|
159
|
-
update! { _1.search(term) }
|
|
158
|
+
update! { _1.respond_to?(:search) ? _1.search(term) : _1.default_search(term) }
|
|
160
159
|
end
|
|
161
160
|
|
|
162
161
|
def query!(params = nil)
|
data/lib/cafe_car/queryable.rb
CHANGED
|
@@ -12,6 +12,25 @@ module CafeCar::Queryable
|
|
|
12
12
|
def query(params) = query_builder.query(params).scope
|
|
13
13
|
def query!(params) = query_builder.query!(params).scope
|
|
14
14
|
|
|
15
|
+
# Turnkey keyword search: case-insensitive match of `term` across the model's
|
|
16
|
+
# string/text columns. Uses Arel `#matches` so it stays DB-portable (ILIKE on
|
|
17
|
+
# Postgres, LIKE on SQLite/MySQL). Columns the parameter filter hides (passwords,
|
|
18
|
+
# tokens, ...) are skipped, mirroring the policy's displayable guarantee. Hosts
|
|
19
|
+
# that declare their own `scope :search` take precedence (see QueryBuilder#search!).
|
|
20
|
+
def default_search(term)
|
|
21
|
+
columns = searchable_columns
|
|
22
|
+
return none if columns.empty?
|
|
23
|
+
pattern = "%#{sanitize_sql_like(term.to_s)}%"
|
|
24
|
+
where(columns.map { arel_table[_1].matches(pattern) }.reduce(:or))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def searchable_columns
|
|
28
|
+
columns_hash.values
|
|
29
|
+
.select { _1.type.in?(%i[string text]) }
|
|
30
|
+
.map(&:name)
|
|
31
|
+
.reject { inspection_filter.filter_param(_1, nil).present? }
|
|
32
|
+
end
|
|
33
|
+
|
|
15
34
|
def query_builder
|
|
16
35
|
CafeCar::QueryBuilder.new(all)
|
|
17
36
|
end
|
data/lib/cafe_car/version.rb
CHANGED
data/lib/cafe_car.rb
CHANGED
|
@@ -23,6 +23,10 @@ module CafeCar
|
|
|
23
23
|
# user model can override this (e.g. `CafeCar.user_class_name = "Account"`).
|
|
24
24
|
mattr_accessor :user_class_name, default: "User"
|
|
25
25
|
|
|
26
|
+
# Maximum number of rows a CSV export will emit. Bounds the memory/latency cost
|
|
27
|
+
# of exporting a large table; truncated exports signal `X-CafeCar-Truncated`.
|
|
28
|
+
mattr_accessor :csv_export_row_limit, default: 10_000
|
|
29
|
+
|
|
26
30
|
# The host's user model, resolved lazily so the constant need not exist at
|
|
27
31
|
# boot. Used by CafeCar::Session for authentication.
|
|
28
32
|
def self.user_class = user_class_name.to_s.constantize
|
|
@@ -13,9 +13,14 @@ class CafeCar::PolicyGenerator < Rails::Generators::NamedBase
|
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
+
# Use the leaf name inside the template; module_namespacing already emits the
|
|
17
|
+
# surrounding `module Admin`, so the full path would double-namespace it
|
|
18
|
+
# (`module Admin; class Admin::PaymentPolicy`). Mirrors the controller generator.
|
|
19
|
+
def class_name = file_name.camelize
|
|
20
|
+
|
|
16
21
|
def base_policy_name = "ApplicationPolicy"
|
|
17
22
|
|
|
18
|
-
def model_class =
|
|
23
|
+
def model_class = file_path.classify.safe_constantize
|
|
19
24
|
|
|
20
25
|
def model = @model ||= CafeCar[:ModelInfo].new(model_class)
|
|
21
26
|
|
|
@@ -2,20 +2,21 @@ require "rails/generators/resource_helpers"
|
|
|
2
2
|
|
|
3
3
|
class CafeCar::ResourceGenerator < Rails::Generators::NamedBase
|
|
4
4
|
include Rails::Generators::ResourceHelpers
|
|
5
|
+
include CafeCar::Generators
|
|
5
6
|
|
|
6
7
|
source_root File.expand_path("templates", __dir__)
|
|
7
8
|
argument :attributes, type: :array, default: [], banner: "field[:type][:index] field[:type][:index]"
|
|
8
9
|
|
|
9
10
|
def create_model
|
|
10
|
-
generate "model", file_path, *attributes, force
|
|
11
|
+
generate "model", file_path, *attributes, force
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def create_controller
|
|
14
|
-
generate "cafe_car:controller", controller_name, force
|
|
15
|
+
generate "cafe_car:controller", controller_name, force
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def create_policy
|
|
18
|
-
generate "cafe_car:policy", file_path, force
|
|
19
|
+
generate "cafe_car:policy", file_path, force
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
private
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cafe_car
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeff Peterson
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-06-30 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: rails
|
|
@@ -177,20 +178,6 @@ dependencies:
|
|
|
177
178
|
- - ">="
|
|
178
179
|
- !ruby/object:Gem::Version
|
|
179
180
|
version: '0.10'
|
|
180
|
-
- !ruby/object:Gem::Dependency
|
|
181
|
-
name: web-console
|
|
182
|
-
requirement: !ruby/object:Gem::Requirement
|
|
183
|
-
requirements:
|
|
184
|
-
- - ">="
|
|
185
|
-
- !ruby/object:Gem::Version
|
|
186
|
-
version: '4.0'
|
|
187
|
-
type: :runtime
|
|
188
|
-
prerelease: false
|
|
189
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
190
|
-
requirements:
|
|
191
|
-
- - ">="
|
|
192
|
-
- !ruby/object:Gem::Version
|
|
193
|
-
version: '4.0'
|
|
194
181
|
- !ruby/object:Gem::Dependency
|
|
195
182
|
name: rouge
|
|
196
183
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -205,20 +192,6 @@ dependencies:
|
|
|
205
192
|
- - ">="
|
|
206
193
|
- !ruby/object:Gem::Version
|
|
207
194
|
version: '4.0'
|
|
208
|
-
- !ruby/object:Gem::Dependency
|
|
209
|
-
name: faker
|
|
210
|
-
requirement: !ruby/object:Gem::Requirement
|
|
211
|
-
requirements:
|
|
212
|
-
- - ">="
|
|
213
|
-
- !ruby/object:Gem::Version
|
|
214
|
-
version: '3.0'
|
|
215
|
-
type: :runtime
|
|
216
|
-
prerelease: false
|
|
217
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
218
|
-
requirements:
|
|
219
|
-
- - ">="
|
|
220
|
-
- !ruby/object:Gem::Version
|
|
221
|
-
version: '3.0'
|
|
222
195
|
description: CafeCar is a Rails engine that extends the view layer to render complete
|
|
223
196
|
index, show, new, and edit interfaces straight from your models, with no boilerplate
|
|
224
197
|
required. Sensible defaults cover authorization, presenters, filtering, sorting,
|
|
@@ -332,8 +305,10 @@ files:
|
|
|
332
305
|
- app/views/application/_index_actions.html.haml
|
|
333
306
|
- app/views/application/_navigation.html.haml
|
|
334
307
|
- app/views/application/_navigation_links.html.haml
|
|
308
|
+
- app/views/application/_nested_field.html.haml
|
|
335
309
|
- app/views/application/_notes.html.haml
|
|
336
310
|
- app/views/application/_popup.html.haml
|
|
311
|
+
- app/views/application/_search.html.haml
|
|
337
312
|
- app/views/application/_show.html.haml
|
|
338
313
|
- app/views/application/_submit.html.haml
|
|
339
314
|
- app/views/application/_table.html.haml
|
|
@@ -475,6 +450,7 @@ metadata:
|
|
|
475
450
|
changelog_uri: https://github.com/craft-concept/cafe_car/blob/main/CHANGELOG.md
|
|
476
451
|
bug_tracker_uri: https://github.com/craft-concept/cafe_car/issues
|
|
477
452
|
rubygems_mfa_required: 'true'
|
|
453
|
+
post_install_message:
|
|
478
454
|
rdoc_options: []
|
|
479
455
|
require_paths:
|
|
480
456
|
- lib
|
|
@@ -489,7 +465,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
489
465
|
- !ruby/object:Gem::Version
|
|
490
466
|
version: '0'
|
|
491
467
|
requirements: []
|
|
492
|
-
rubygems_version: 3.
|
|
468
|
+
rubygems_version: 3.5.22
|
|
469
|
+
signing_key:
|
|
493
470
|
specification_version: 4
|
|
494
471
|
summary: Auto-generate CRUD admin UI for your Rails models, with sensible overridable
|
|
495
472
|
defaults.
|