cafe_car 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +682 -8
  3. data/Rakefile +21 -0
  4. data/app/assets/fonts/Lexend.css +7 -0
  5. data/app/assets/fonts/Lexend.ttf +0 -0
  6. data/app/assets/images/noise.svg +16 -0
  7. data/app/assets/stylesheets/actiontext.css +31 -0
  8. data/app/assets/stylesheets/application.css +1 -0
  9. data/app/assets/stylesheets/cafe_car/code/base16-dark.css +89 -0
  10. data/app/assets/stylesheets/cafe_car/code/base16-light.css +90 -0
  11. data/app/assets/stylesheets/cafe_car/pagination.css +5 -0
  12. data/app/assets/stylesheets/cafe_car/themes/cool.css +32 -0
  13. data/app/assets/stylesheets/cafe_car/themes/cool2.css +31 -0
  14. data/app/assets/stylesheets/cafe_car/themes/defaults.css +60 -0
  15. data/app/assets/stylesheets/cafe_car/themes/warm-dark.css +29 -0
  16. data/app/assets/stylesheets/cafe_car/themes/warm.css +24 -0
  17. data/app/assets/stylesheets/cafe_car/tooltips.css +20 -0
  18. data/app/assets/stylesheets/cafe_car/trix.css +56 -0
  19. data/app/assets/stylesheets/cafe_car/utility.css +63 -0
  20. data/app/assets/stylesheets/cafe_car.css +96 -0
  21. data/app/assets/stylesheets/iconoir.css +22 -0
  22. data/app/assets/stylesheets/ui/Alert.css +25 -0
  23. data/app/assets/stylesheets/ui/Article.css +11 -0
  24. data/app/assets/stylesheets/ui/Button.css +42 -0
  25. data/app/assets/stylesheets/ui/Card.css +74 -0
  26. data/app/assets/stylesheets/ui/Chat.css +33 -0
  27. data/app/assets/stylesheets/ui/Close.css +11 -0
  28. data/app/assets/stylesheets/ui/Code.css +4 -0
  29. data/app/assets/stylesheets/ui/Controls.css +16 -0
  30. data/app/assets/stylesheets/ui/Error.css +3 -0
  31. data/app/assets/stylesheets/ui/Example.css +45 -0
  32. data/app/assets/stylesheets/ui/Field.css +31 -0
  33. data/app/assets/stylesheets/ui/Grid.css +6 -0
  34. data/app/assets/stylesheets/ui/Group.css +16 -0
  35. data/app/assets/stylesheets/ui/Icon.css +27 -0
  36. data/app/assets/stylesheets/ui/Image.css +14 -0
  37. data/app/assets/stylesheets/ui/InfoCircle.css +11 -0
  38. data/app/assets/stylesheets/ui/Input.css +36 -0
  39. data/app/assets/stylesheets/ui/Layout.css +100 -0
  40. data/app/assets/stylesheets/ui/Menu.css +38 -0
  41. data/app/assets/stylesheets/ui/Modal.css +26 -0
  42. data/app/assets/stylesheets/ui/Navigation.css +37 -0
  43. data/app/assets/stylesheets/ui/Page.css +105 -0
  44. data/app/assets/stylesheets/ui/Row.css +9 -0
  45. data/app/assets/stylesheets/ui/Table.css +101 -0
  46. data/app/assets/stylesheets/ui/components.css +24 -0
  47. data/app/controllers/cafe_car/application_controller.rb +9 -0
  48. data/app/controllers/cafe_car/examples_controller.rb +22 -0
  49. data/app/controllers/cafe_car/sessions_controller.rb +30 -0
  50. data/app/controllers/concerns/cafe_car/authentication.rb +61 -0
  51. data/app/javascript/application.js +5 -0
  52. data/app/javascript/cafe_car.js +174 -0
  53. data/app/models/cafe_car/session.rb +18 -0
  54. data/app/policies/cafe_car/application_policy.rb +42 -0
  55. data/app/policies/cafe_car/session_policy.rb +19 -0
  56. data/app/presenters/cafe_car/action_text/rich_text_presenter.rb +7 -0
  57. data/app/presenters/cafe_car/active_record/base_presenter.rb +6 -0
  58. data/app/presenters/cafe_car/active_record/relation_presenter.rb +17 -0
  59. data/app/presenters/cafe_car/active_storage/attached/one_presenter.rb +9 -0
  60. data/app/presenters/cafe_car/active_storage/attachment_presenter.rb +18 -0
  61. data/app/presenters/cafe_car/basic_object_presenter.rb +5 -0
  62. data/app/presenters/cafe_car/code_presenter.rb +18 -0
  63. data/app/presenters/cafe_car/currency_presenter.rb +5 -0
  64. data/app/presenters/cafe_car/date_and_time/compatibility_presenter.rb +6 -0
  65. data/app/presenters/cafe_car/date_presenter.rb +5 -0
  66. data/app/presenters/cafe_car/date_time_presenter.rb +11 -0
  67. data/app/presenters/cafe_car/enumerable_presenter.rb +13 -0
  68. data/app/presenters/cafe_car/false_class_presenter.rb +5 -0
  69. data/app/presenters/cafe_car/hash_presenter.rb +6 -0
  70. data/app/presenters/cafe_car/nil_class_presenter.rb +13 -0
  71. data/app/presenters/cafe_car/presenter.rb +157 -0
  72. data/app/presenters/cafe_car/range_presenter.rb +16 -0
  73. data/app/presenters/cafe_car/record_presenter.rb +5 -0
  74. data/app/presenters/cafe_car/string_presenter.rb +20 -0
  75. data/app/presenters/cafe_car/symbol_presenter.rb +5 -0
  76. data/app/presenters/cafe_car/true_class_presenter.rb +5 -0
  77. data/app/ui/cafe_car/ui/button.rb +9 -0
  78. data/app/ui/cafe_car/ui/card.rb +18 -0
  79. data/app/ui/cafe_car/ui/field.rb +11 -0
  80. data/app/ui/cafe_car/ui/grid.rb +30 -0
  81. data/app/ui/cafe_car/ui/layout.rb +7 -0
  82. data/app/ui/cafe_car/ui/page.rb +14 -0
  83. data/app/views/application/_actions.html.haml +1 -0
  84. data/app/views/application/_alerts.html.haml +2 -0
  85. data/app/views/application/_body.html.haml +7 -0
  86. data/app/views/application/_controls.html.haml +12 -0
  87. data/app/views/application/_debug.html.haml +18 -0
  88. data/app/views/application/_empty.html.haml +1 -0
  89. data/app/views/application/_errors.html.haml +4 -0
  90. data/app/views/application/_field.html.haml +5 -0
  91. data/app/views/application/_fields.html.haml +1 -0
  92. data/app/views/application/_filters.html.haml +8 -0
  93. data/app/views/application/_form.html.haml +6 -0
  94. data/app/views/application/_grid.html.haml +3 -0
  95. data/app/views/application/_grid_item.html.haml +1 -0
  96. data/app/views/application/_head.html.haml +17 -0
  97. data/app/views/application/_index.html.haml +8 -0
  98. data/app/views/application/_index_actions.html.haml +7 -0
  99. data/app/views/application/_navigation.html.haml +9 -0
  100. data/app/views/application/_navigation_links.html.haml +5 -0
  101. data/app/views/application/_notes.html.haml +10 -0
  102. data/app/views/application/_popup.html.haml +7 -0
  103. data/app/views/application/_show.html.haml +9 -0
  104. data/app/views/application/_submit.html.haml +1 -0
  105. data/app/views/application/_table.html.haml +6 -0
  106. data/app/views/cafe_car/application/create.turbo_stream.haml +2 -0
  107. data/app/views/cafe_car/application/destroy.turbo_stream.haml +1 -0
  108. data/app/views/cafe_car/application/edit.html.haml +18 -0
  109. data/app/views/cafe_car/application/edit.turbo_stream.haml +7 -0
  110. data/app/views/cafe_car/application/index.html.haml +15 -0
  111. data/app/views/cafe_car/application/new.html.haml +8 -0
  112. data/app/views/cafe_car/application/new.turbo_stream.haml +8 -0
  113. data/app/views/cafe_car/application/show.html.haml +36 -0
  114. data/app/views/cafe_car/application/update.turbo_stream.haml +2 -0
  115. data/app/views/cafe_car/examples/_example.html.haml +12 -0
  116. data/app/views/cafe_car/examples/_index.html.haml +16 -0
  117. data/app/views/cafe_car/examples/_navigation_links.html.haml +2 -0
  118. data/app/views/cafe_car/examples/ui/_alert.html.haml +4 -0
  119. data/app/views/cafe_car/examples/ui/_button.html.haml +3 -0
  120. data/app/views/cafe_car/examples/ui/_card.html.haml +6 -0
  121. data/app/views/cafe_car/examples/ui/_chat.html.haml +3 -0
  122. data/app/views/cafe_car/examples/ui/_controls.html.haml +3 -0
  123. data/app/views/cafe_car/examples/ui/_error.html.haml +1 -0
  124. data/app/views/cafe_car/examples/ui/_field.html.haml +9 -0
  125. data/app/views/cafe_car/examples/ui/_grid.html.haml +11 -0
  126. data/app/views/cafe_car/examples/ui/_group.html.haml +21 -0
  127. data/app/views/cafe_car/examples/ui/_info_circle.html.haml +1 -0
  128. data/app/views/cafe_car/examples/ui/_menu.html.haml +5 -0
  129. data/app/views/cafe_car/examples/ui/_modal.html.haml +4 -0
  130. data/app/views/cafe_car/examples/ui/_navigation.html.haml +4 -0
  131. data/app/views/cafe_car/examples/ui/_page.html.haml +4 -0
  132. data/app/views/cafe_car/examples/ui/_table.html.haml +13 -0
  133. data/app/views/cafe_car/layouts/mailer.html.haml +8 -0
  134. data/app/views/cafe_car/layouts/mailer.text.erb +1 -0
  135. data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
  136. data/app/views/layouts/application.html.haml +4 -0
  137. data/app/views/layouts/mailer.html.erb +13 -0
  138. data/app/views/layouts/mailer.text.erb +1 -0
  139. data/app/views/notes/_fields.html.haml +1 -0
  140. data/app/views/passwords_mailer/reset.html.haml +5 -0
  141. data/app/views/passwords_mailer/reset.text.erb +4 -0
  142. data/app/views/ui/_card.html.haml +8 -0
  143. data/app/views/ui/_field.html.haml +1 -0
  144. data/app/views/ui/_modal_close.html.haml +1 -0
  145. data/app/views/ui/_page.html.haml +7 -0
  146. data/config/brakeman.ignore +77 -0
  147. data/config/importmap.rb +12 -0
  148. data/config/locales/en.yml +63 -0
  149. data/config/routes.rb +9 -0
  150. data/db/migrate/20251005220017_create_slugs.rb +13 -0
  151. data/lib/cafe_car/active_record.rb +21 -0
  152. data/lib/cafe_car/application_responder.rb +17 -0
  153. data/lib/cafe_car/attributes.rb +23 -0
  154. data/lib/cafe_car/auto_resolver.rb +49 -0
  155. data/lib/cafe_car/caching.rb +20 -0
  156. data/lib/cafe_car/component.rb +155 -0
  157. data/lib/cafe_car/context.rb +17 -0
  158. data/lib/cafe_car/controller/filtering.rb +30 -0
  159. data/lib/cafe_car/controller.rb +218 -0
  160. data/lib/cafe_car/core_ext/array.rb +24 -0
  161. data/lib/cafe_car/core_ext/hash.rb +15 -0
  162. data/lib/cafe_car/core_ext/module.rb +15 -0
  163. data/lib/cafe_car/core_ext.rb +5 -0
  164. data/lib/cafe_car/current.rb +9 -0
  165. data/lib/cafe_car/engine.rb +107 -0
  166. data/lib/cafe_car/field_builder.rb +44 -0
  167. data/lib/cafe_car/field_info.rb +144 -0
  168. data/lib/cafe_car/fields.rb +21 -0
  169. data/lib/cafe_car/filter/field_builder.rb +4 -0
  170. data/lib/cafe_car/filter/field_info.rb +22 -0
  171. data/lib/cafe_car/filter/form_builder.rb +21 -0
  172. data/lib/cafe_car/filter.rb +5 -0
  173. data/lib/cafe_car/filter_builder.rb +20 -0
  174. data/lib/cafe_car/form_builder.rb +105 -0
  175. data/lib/cafe_car/generators.rb +30 -0
  176. data/lib/cafe_car/helpers.rb +178 -0
  177. data/lib/cafe_car/href_builder.rb +97 -0
  178. data/lib/cafe_car/informable.rb +9 -0
  179. data/lib/cafe_car/input_builder.rb +25 -0
  180. data/lib/cafe_car/inputs/association_builder.rb +6 -0
  181. data/lib/cafe_car/inputs/base_input.rb +19 -0
  182. data/lib/cafe_car/inputs/belongs_to_builder.rb +6 -0
  183. data/lib/cafe_car/inputs/password_input.rb +7 -0
  184. data/lib/cafe_car/inputs/string_input.rb +7 -0
  185. data/lib/cafe_car/link_builder.rb +65 -0
  186. data/lib/cafe_car/model.rb +23 -0
  187. data/lib/cafe_car/model_info.rb +24 -0
  188. data/lib/cafe_car/name_patch.rb +17 -0
  189. data/lib/cafe_car/navigation.rb +76 -0
  190. data/lib/cafe_car/option_helpers.rb +53 -0
  191. data/lib/cafe_car/param_parser.rb +45 -0
  192. data/lib/cafe_car/pluralization.rb +15 -0
  193. data/lib/cafe_car/policy.rb +77 -0
  194. data/lib/cafe_car/proc_helpers.rb +13 -0
  195. data/lib/cafe_car/query_builder.rb +186 -0
  196. data/lib/cafe_car/queryable.rb +29 -0
  197. data/lib/cafe_car/resolver.rb +27 -0
  198. data/lib/cafe_car/routing.rb +17 -0
  199. data/lib/cafe_car/table/body_builder.rb +12 -0
  200. data/lib/cafe_car/table/builder.rb +52 -0
  201. data/lib/cafe_car/table/foot_builder.rb +14 -0
  202. data/lib/cafe_car/table/head_builder.rb +26 -0
  203. data/lib/cafe_car/table/label_builder.rb +48 -0
  204. data/lib/cafe_car/table/objects_builder.rb +8 -0
  205. data/lib/cafe_car/table/row_builder.rb +39 -0
  206. data/lib/cafe_car/table_builder.rb +13 -0
  207. data/lib/cafe_car/turbo_tag_builder.rb +7 -0
  208. data/lib/cafe_car/ui.rb +11 -0
  209. data/lib/cafe_car/version.rb +1 -1
  210. data/lib/cafe_car/visitors.rb +21 -0
  211. data/lib/cafe_car.rb +25 -168
  212. data/lib/generators/cafe_car/controller/USAGE +11 -0
  213. data/lib/generators/cafe_car/controller/controller_generator.rb +26 -0
  214. data/lib/generators/cafe_car/controller/templates/controller.rb.tt +5 -0
  215. data/lib/generators/cafe_car/install/USAGE +8 -0
  216. data/lib/generators/cafe_car/install/install_generator.rb +46 -0
  217. data/lib/generators/cafe_car/install/templates/application_policy.rb.tt +7 -0
  218. data/lib/generators/cafe_car/notes/USAGE +12 -0
  219. data/lib/generators/cafe_car/notes/notes_generator.rb +13 -0
  220. data/lib/generators/cafe_car/notes/templates/create_notes.rb.tt +12 -0
  221. data/lib/generators/cafe_car/notes/templates/notable.rb.tt +7 -0
  222. data/lib/generators/cafe_car/notes/templates/note.rb.tt +6 -0
  223. data/lib/generators/cafe_car/policy/USAGE +8 -0
  224. data/lib/generators/cafe_car/policy/policy_generator.rb +39 -0
  225. data/lib/generators/cafe_car/policy/templates/policy.rb.tt +20 -0
  226. data/lib/generators/cafe_car/resource/USAGE +13 -0
  227. data/lib/generators/cafe_car/resource/resource_generator.rb +32 -0
  228. data/lib/generators/cafe_car/sessions/USAGE +17 -0
  229. data/lib/generators/cafe_car/sessions/sessions_generator.rb +29 -0
  230. data/lib/generators/cafe_car/sessions/templates/create_sessions.rb.tt +12 -0
  231. data/lib/tasks/holdco_tasks.rake +532 -0
  232. data/lib/tasks/templates/tasks_header.md +37 -0
  233. metadata +444 -21
  234. data/app/views/cafe_car/application/_fields.html.erb +0 -7
  235. data/app/views/cafe_car/application/_filters.html.erb +0 -0
  236. data/app/views/cafe_car/application/_form.html.erb +0 -22
  237. data/lib/cafe_car/railtie.rb +0 -4
  238. /data/app/views/{cafe_car/application/_actions.html.erb → application/_aside.html.haml} +0 -0
  239. /data/app/views/{cafe_car/application/_aside.html.erb → application/_footer.html.haml} +0 -0
  240. /data/app/views/cafe_car/{application/_extra_fields.html.erb → examples/_index_actions.html.haml} +0 -0
@@ -0,0 +1,144 @@
1
+ module CafeCar
2
+ class FieldInfo
3
+ attr_reader :method, :model
4
+ alias_method :name, :method
5
+
6
+ delegate :model_name, to: :@model
7
+
8
+ def initialize(model:, method:)
9
+ # raise "Cannot get FieldInfo for nil method" if method.nil?
10
+ @method = method&.to_sym
11
+ @model = model
12
+ end
13
+
14
+ def info(method) = model.info.field(method)
15
+
16
+ def id? = method =~ /_ids?$/
17
+ def constant? = method.in? %i[id created_at updated_at]
18
+ def timestamp? = type.in? %i[date datetime time]
19
+
20
+ def association?
21
+ return if @method.nil?
22
+ model.reflect_on_association(@method).present?
23
+ end
24
+
25
+ def associated? = reflection.present?
26
+ def polymorphic? = reflection&.polymorphic?
27
+ def digest? = method =~ /_digest$/
28
+ def password? = type == :password
29
+ def rich_text? = reflection&.name =~ /^rich_text_(\w+)$/
30
+ def attachment? = model.reflect_on_attachment(method)
31
+ def collection = reflection.klass.all
32
+ def reflection
33
+ return if @method.nil?
34
+ model.try(:reflect_on_association, @method) || reflections_by_attribute[@method]
35
+ end
36
+
37
+ def abrogated_keys
38
+ [ *reflection&.foreign_type&.to_sym ]
39
+ end
40
+
41
+ def displayable = reflection&.name&.then { info(_1) } || self
42
+
43
+ def default_type
44
+ case method
45
+ when :controls then method
46
+ end
47
+ end
48
+
49
+ def reflection_type = reflection&.macro
50
+ def attribute_type = model.type_for_attribute(@method)&.type
51
+ def digest_type
52
+ key = @method.to_s.chomp("_confirmation")
53
+ model.type_for_attribute("#{key}_digest")&.type && :password
54
+ end
55
+
56
+ def attachment_type
57
+ :attachment if attachment?
58
+ end
59
+
60
+ def nested_attributes_type
61
+ key = @method.to_s.chomp("_attributes").to_sym
62
+ model.nested_attributes_options.key?(key) && :nested
63
+ end
64
+
65
+ def polymorphic_methods = [ reflection.foreign_type, reflection.foreign_key ]
66
+
67
+ def placeholder = i18n(:placeholder)
68
+ def autocomplete = i18n(:autocomplete)
69
+ def hint = i18n(:hint)
70
+ def label = i18n(:label, default: human)
71
+ def prompt = i18n(:prompt, default: "Select #{human.downcase}...")
72
+ def human(...) = @method&.then { model.human_attribute_name(_1, ...) }
73
+ def required? = validator?(:presence)
74
+
75
+ def validator?(kind, **options)
76
+ model.validators_on(@method).any? { _1.kind == kind and _1.options >= options }
77
+ end
78
+
79
+ def i18n_key = model_name.i18n_key
80
+ def i18n(key, **opts)
81
+ return if @method.nil?
82
+ I18n.t(@method, scope: [ :helpers, key, i18n_key ], raise: true, **opts)
83
+ rescue I18n::MissingTranslationData
84
+ end
85
+
86
+ def type
87
+ return if @method.nil?
88
+ @type ||= reflection_type || attribute_type || digest_type || attachment_type ||
89
+ default_type || nested_attributes_type ||
90
+ raise(NoMethodError.new "Can't find attribute :#{@method} on #{model_name}", @method)
91
+ end
92
+
93
+ def width
94
+ case type
95
+ when :text, :string, :json
96
+ # "minmax(10em, fit-content)"
97
+ # "minmax(10em, 1fr)"
98
+ "minmax(10em, auto)"
99
+ # "minmax(min-content, auto)"
100
+ else "min-content"
101
+ end
102
+ end
103
+
104
+ def input
105
+ case type
106
+ when :string then :text_field
107
+ when :decimal then :text_field
108
+ when :text, :json then :text_area
109
+ when :integer then :number_field
110
+ when :date then :date_field
111
+ when :datetime then :datetime_field
112
+ when :password then :password_field
113
+ when :nested then :fields_for
114
+ when :attachment then :file_field
115
+ when :belongs_to, :has_many then :association
116
+ when :has_one
117
+ rich_text? ? :rich_text_area : nil
118
+ else raise "Missing input type for #{model_name}##{@method} of type :#{type}"
119
+ end
120
+ end
121
+
122
+ def input_key
123
+ case type
124
+ when :belongs_to then reflection.foreign_key
125
+ else method
126
+ end
127
+ end
128
+
129
+ @@reflections_by_attribute = {}
130
+ def reflections_by_attribute
131
+ return {} unless model.respond_to? :reflections
132
+ @@reflections_by_attribute[model] ||=
133
+ model.reflections.values.index_by do |r|
134
+ case [ r.macro, r.name ]
135
+ in [ :belongs_to, * ] then r.foreign_key
136
+ in [ :has_many, * ] then "#{r.name.to_s.singularize}_ids"
137
+ in [ :has_one, /^rich_text_(\w+)$/ ] then $1
138
+ in [ :has_one, * ] then r.name
139
+ else raise NoMethodError.new("Not yet implemented :#{r.macro}")
140
+ end
141
+ end.with_indifferent_access
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,21 @@
1
+ module CafeCar
2
+ class Fields < Array
3
+ include Caching
4
+
5
+ derive :editable, -> { Fields.new reject(&:constant?) }
6
+ derive :listable, -> { Fields.new editable.reject(&:digest?) }
7
+ derive :attachments, -> { Fields.new select(&:attachment?) }
8
+ derive :timestamps, -> { Fields.new select(&:timestamp?) }
9
+
10
+ derive :by_name, -> { index_by(&:name).with_indifferent_access }
11
+ derive :names, -> { map(&:name) }
12
+
13
+ def reverse = Fields.new(super)
14
+
15
+ def sort_with(obj)
16
+ Fields.new(sort_by { obj.try(_1.name) })
17
+ end
18
+
19
+ def has?(name) = by_name.key?(name)
20
+ end
21
+ end
@@ -0,0 +1,4 @@
1
+ module CafeCar::Filter
2
+ class FieldBuilder < CafeCar[:FieldBuilder]
3
+ end
4
+ end
@@ -0,0 +1,22 @@
1
+ class CafeCar::Filter::FieldInfo < CafeCar[:FieldInfo]
2
+ def i18n(key, **opts)
3
+ I18n.t(@method, scope: [ :helpers, :filter, key, i18n_key ], raise: true, **opts)
4
+ rescue I18n::MissingTranslationData
5
+ end
6
+
7
+ def input
8
+ case type
9
+ when :string then :text_field
10
+ when :text then :text_field
11
+ when :decimal then :text_field # :range_field
12
+ when :integer then :text_field # :range_field
13
+ when :date then :text_field
14
+ when :datetime then :text_field
15
+ when :password then :password_field
16
+ when :belongs_to, :has_many then :association
17
+ when :has_one
18
+ rich_text? ? :text_field : nil
19
+ else raise "Missing input type for #{model_name}##{@method} of type :#{type}"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ module CafeCar::Filter
2
+ class FormBuilder < CafeCar[:FormBuilder]
3
+ def self.dotted_name(method)
4
+ define_method method do |key, *args, **opts, &block|
5
+ super(key, *args, name: field_name(key), **opts, &block)
6
+ end
7
+ end
8
+
9
+ instance_methods.grep(/_(field|select)$/).each do |method|
10
+ dotted_name method
11
+ end
12
+
13
+ def clean(method) = method.to_s.sub(/^\W+|\W+$/, "")
14
+ def info(method) = super(clean(method))
15
+
16
+ def field_name(*methods, multiple: false, index: @options[:index])
17
+ # TODO: handle multiple/index
18
+ [ "", *methods ].join(".")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ module CafeCar
2
+ module Filter
3
+ include Resolver
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ module CafeCar
2
+ class FilterBuilder
3
+ delegate :model_name, :klass, to: :@objects
4
+
5
+ def initialize(objects, params)
6
+ @objects = objects
7
+ @params = params
8
+ end
9
+
10
+ def model = @objects.klass
11
+ def to_key = [ model_name.param_key, :filters ]
12
+ def to_model = self
13
+ def persisted? = false
14
+ def errors = Hash.new([])
15
+
16
+ def method_missing(name, *, &)
17
+ @params.dig("", name)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,105 @@
1
+ module CafeCar
2
+ class FormBuilder < ActionView::Helpers::FormBuilder
3
+ include Resolver
4
+
5
+ delegate :ui, to: :@template
6
+
7
+ def initialize(...)
8
+ super
9
+ @fields = {}
10
+ end
11
+
12
+ def model = @object.is_a?(Class) ? @object : @object.class
13
+ def policy = @template.policy(@object)
14
+ def show(...) = ui.Input { @template.present(@object).show(...) }
15
+
16
+ def association(method, collection: nil, **options)
17
+ info = info(method)
18
+
19
+ return show(info.input_key) if info.polymorphic? and object.persisted?
20
+ return hidden(*info.polymorphic_methods) if info.polymorphic?
21
+
22
+ collection ||= info.collection
23
+ # options[:prompt] ||= info.prompt
24
+ options[:include_blank] ||= info.prompt
25
+
26
+ input(info.input_key, collection, :id, -> { @template.present(_1).title }, as: :collection_select, **options)
27
+ end
28
+
29
+ def hidden(*methods, **, &)
30
+ methods.map { input(_1, as: :hidden_field, **, &) }
31
+ .then { @template.safe_join(_1) }
32
+ end
33
+
34
+ def field(method, **, &)
35
+ @fields[method] ||= const(:FieldBuilder).new(method:, form: self, template: @template, **, &)
36
+ end
37
+
38
+ def label(method, text = info(method).label, required: info(method).required?, **, &)
39
+ super(method, @template.safe_join([ text, required ? "*" : "" ]), required:, **, &)
40
+ end
41
+
42
+ def submit(value = nil, **options)
43
+ options[:class] ||= ui.class(:button, :primary)
44
+ super(value, options)
45
+ end
46
+
47
+ def info(method) = model.info.field(method)
48
+
49
+ def input(method, *args, as: nil, **options)
50
+ info = info(method)
51
+ as ||= info.input
52
+
53
+ options[:placeholder] = info.placeholder unless options.key?(:placeholder)
54
+ options[:autocomplete] = info.autocomplete unless options.key?(:autocomplete)
55
+
56
+ public_send(as, method, *args, **options)
57
+ end
58
+
59
+ def hint(method, text = info(method).hint, **)
60
+ @template.tag.small(text, **) if text.present?
61
+ end
62
+
63
+ def errors(method)
64
+ errors = object.try(:errors)
65
+ associated = info(method).reflection&.then { errors[_1.name] } || []
66
+ errors[method] | associated
67
+ end
68
+
69
+ def error_text(method)
70
+ errors(method).to_sentence.presence
71
+ end
72
+
73
+ def error(method, text = error_text(method), **)
74
+ @template.tag.span(text, **) if text.present?
75
+ end
76
+
77
+ def fields_for(method, object = object_for(method), **, &)
78
+ method = method.to_s.chomp("_attributes").to_sym
79
+ if block_given?
80
+ super
81
+ else
82
+ super(method, **) do |f|
83
+ f.remaining_fields
84
+ end
85
+ end
86
+ end
87
+
88
+ def object_for(method)
89
+ method = method.to_s.chomp("_attributes").to_sym
90
+ if info(method).reflection.collection?
91
+ object.try(method)
92
+ else
93
+ object.try(method) || object.try("build_#{method}")
94
+ end
95
+ end
96
+
97
+ def remaining_attributes = policy.editable_attributes - @fields.keys
98
+
99
+ def remaining_fields(**, &block)
100
+ block ||= proc { field(_1, **) }
101
+ fields = remaining_attributes.map(&block)
102
+ @template.safe_join(fields)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,30 @@
1
+ require "rails/generators/active_record/migration"
2
+ require "rails/generators/bundle_helper"
3
+
4
+ module CafeCar::Generators
5
+ extend ActiveSupport::Concern
6
+ include ActiveRecord::Generators::Migration
7
+ include Rails::Generators::BundleHelper
8
+
9
+ private
10
+
11
+ def migration(name, ...) = migration_template("#{name}.rb", "db/migrate/#{name}.rb", ...)
12
+
13
+ def model(name, ...)
14
+ name = name.chomp(".rb").underscore
15
+ template("#{name}.rb", "app/models/#{name}.rb", ...)
16
+ end
17
+
18
+ def class_namespace = class_path.join("/").classify
19
+
20
+ def module_namespacing(&block)
21
+ super { concat wrap_with_module(class_namespace, &block) }
22
+ end
23
+
24
+ def wrap_with_module(module_name, &block)
25
+ content = capture(&block)
26
+ return content if module_name.blank?
27
+ content = indent(content).chomp
28
+ "module #{module_name}\n#{content}\nend\n"
29
+ end
30
+ end
@@ -0,0 +1,178 @@
1
+ module CafeCar
2
+ module Helpers
3
+ # Returns a new `Context`. Used for instantiating components: `ui.Button(:primary, "Submit")`
4
+ def ui(*args, **, &)
5
+ # For now, this must be defined in a helper instead of in the controller. Passing `view_context` or `helpers`
6
+ # from the controller somehow breaks `capture`. `capture` will return the captured content, but the content
7
+ # _also_ gets appended to the original output buffer.
8
+ # This can be tested in a view by comparing the behavior of `= capture do` with
9
+ # `= controller.view_context.capture do`; the latter outputs the content twice.
10
+ if args.any?
11
+ present(*args, **, &)
12
+ elsif block_given?
13
+ capture(&)
14
+ else
15
+ @ui ||= CafeCar::Context.new(self)
16
+ end
17
+ end
18
+
19
+ def ui_class(names, *args, **opts)
20
+ names = [ *names ].map(&:camelize)
21
+ name = names.join("_")
22
+ args.flatten!
23
+ args.compact_blank!
24
+ opts.compact_blank!
25
+ opts.merge!(*args.extract!(Hash))
26
+
27
+ flags = args.extract!(Symbol)
28
+ flags |= opts.extract_if! { _1.is_a? Symbol }.keys
29
+ flags.map! { "#{name}-#{_1}" }
30
+
31
+ [ *name, *flags, *args, *opts.keys ].join(" ")
32
+ end
33
+
34
+ def body_classes = [ *controller_path.split(?/), action_name, *@body_class ]
35
+
36
+ def title(object)
37
+ present(object).title.presence.tap do |title|
38
+ content_for(:title, title)
39
+ end
40
+ end
41
+
42
+ def cat(*args)
43
+ args.flatten.each do |arg|
44
+ arg = capture(&arg) if arg.respond_to?(:to_proc)
45
+ arg = arg.to_s
46
+ concat(arg) if arg.present?
47
+ end
48
+ end
49
+
50
+ def cap(*)
51
+ capture { cat(*) }
52
+ end
53
+
54
+ def capture(*, &)
55
+ super do
56
+ yield(*).then { _1.try(:html_safe?) ? _1.to_s : _1 }
57
+ end
58
+ end
59
+
60
+ def present(*args, **options)
61
+ @presenters ||= {}
62
+ @presenters[[ args, options ]] ||= CafeCar[:Presenter].present(self, *args, **options)
63
+ end
64
+ alias_method :p, :present
65
+
66
+ def current_href?(*, check_parameters: false, **) = current_page?(href_for(*, **), check_parameters:)
67
+ def ancestor_href?(...) = URI(href_for(...)) < URI(url_for(request.url))
68
+
69
+ def href_for(*parts, namespace: self.namespace, **params)
70
+ HrefBuilder.new(*parts, namespace:, template: self, **params).to_s
71
+ end
72
+
73
+ def view_url(view)
74
+ view = view.to_s
75
+ params = request.params.merge(view:)
76
+ params.delete(:view) if params[:view] == default_view
77
+ url_for(params)
78
+ end
79
+
80
+ def context(name = nil, &)
81
+ @context ||= []
82
+
83
+ if block_given?
84
+ @context << name
85
+ r = capture(&)
86
+ @context.pop
87
+ r
88
+ else
89
+ @context
90
+ end
91
+ end
92
+
93
+ def context?(*names)
94
+ context.reverse_each do |ctx|
95
+ return true if names.empty?
96
+ names.pop if ctx == names.last
97
+ end
98
+ names.empty?
99
+ end
100
+
101
+ def link(object)
102
+ @links ||= {}
103
+ @links[object] ||= CafeCar[:LinkBuilder].new(self, object)
104
+ end
105
+
106
+ def link_to(...)
107
+ raise ArgumentError, "Links cannot be nested" if context?(:a)
108
+ context(:a) { super }
109
+ end
110
+
111
+ def icon(name = nil, *, **, &)
112
+ case name
113
+ when Symbol
114
+ class_ = name&.then { "iconoir-#{_1.to_s.dasherize}" }
115
+ when String, Array
116
+ label = name
117
+ end
118
+
119
+ ui.Icon(*label, *, tag: :i, class: class_, **, &)
120
+ end
121
+
122
+ def breadcrumbs(*items)
123
+ ui.Row safe_join(items.compact_blank, icon(:nav_arrow_right, :dim))
124
+ end
125
+
126
+ def filter_form_for(objects, **options, &block)
127
+ raise ArgumentError, "First argument to filter_form_for cannot be nil" if objects.nil?
128
+
129
+ form_for CafeCar[:FilterBuilder].new(objects, parsed_params),
130
+ builder: CafeCar["Filter::FormBuilder"],
131
+ method: :get,
132
+ url: "",
133
+ as: "",
134
+ **options,
135
+ &block
136
+ end
137
+
138
+ def table_for(objects, **options, &block)
139
+ CafeCar[:TableBuilder].new(self, objects:, **options, &block)
140
+ end
141
+
142
+ def debug? = params.key?(:debug)
143
+ def console? = params.key?(:console)
144
+
145
+ def comment(text)
146
+ "<!-- #{text} -->".html_safe
147
+ end
148
+
149
+ def partial?(path)
150
+ prefixes = path.include?(?/) ? [] : lookup_context.prefixes
151
+ lookup_context.any?(path, prefixes, true)
152
+ end
153
+
154
+ def get_partial(path)
155
+ prefixes = path.include?(?/) ? [] : lookup_context.prefixes
156
+ lookup_context.find(path, prefixes, true)
157
+ end
158
+
159
+ def template_glob(glob)
160
+ lookup_context.view_paths
161
+ .flat_map { _1.send(:template_glob, glob) }
162
+ .map { ActionView::TemplatePath.parse(_1) }
163
+ end
164
+
165
+ def navigation
166
+ @navigation ||= CafeCar::Navigation.new(self)
167
+ end
168
+
169
+ def namespace
170
+ @namespace ||= controller_path.split("/").tap(&:pop).map(&:to_sym)
171
+ end
172
+
173
+ def method_missing(name, ...)
174
+ return ui.send(name, ...) if name =~ /^[A-Z]/
175
+ super
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,97 @@
1
+ module CafeCar
2
+ class HrefBuilder
3
+ attr_reader :parts, :namespace
4
+
5
+ def initialize(*parts, namespace: [], template: nil, **params)
6
+ @params = params
7
+ @parts = parts
8
+ @namespace = namespace
9
+ @template = template
10
+ normalize!
11
+ end
12
+
13
+ def to_s
14
+ case [ *@parts ]
15
+ in [ String ]
16
+ [ *@parts, *@params.to_query.presence ].join(??)
17
+ in []
18
+ @template.url_for(@params)
19
+ else
20
+ namespace = collapsed_namespace
21
+ parts = @parts.map { singular_resource(_1) }
22
+ begin
23
+ @template.url_for([ *namespace, *parts, @params ])
24
+ rescue NoMethodError
25
+ raise if namespace.empty?
26
+ namespace.pop
27
+ retry
28
+ end
29
+ end
30
+ end
31
+
32
+ def normalize!
33
+ @parts.flatten!
34
+ @params.with_defaults!(@parts.extract_options!)
35
+ @namespace = @namespace.underscore.split(?/).map(&:to_sym) if @namespace.is_a? String
36
+ @params.delete(:action) if @params[:action].in? %i[show destroy index]
37
+ self
38
+ end
39
+
40
+ def collapsed_namespace
41
+ 0.upto(@namespace.size) do |i|
42
+ if parts_start_with? @namespace.drop(i)
43
+ return @namespace.slice(0, i)
44
+ end
45
+ end
46
+ end
47
+
48
+ def expanded_parts
49
+ @expanded_parts ||= @parts.flat_map { expand_part _1 }
50
+ end
51
+
52
+ private
53
+
54
+ # Records route polymorphically via the plural `route_key`. For a singular
55
+ # resource (`resource :session`) that helper doesn't exist, so fall back to
56
+ # the singular route key. Leaves non-records (symbols, strings) untouched.
57
+ def singular_resource(part)
58
+ name = model_name_for(part) or return part
59
+ return part if @template.respond_to?("#{name.route_key}_path")
60
+
61
+ @template.respond_to?("#{name.singular_route_key}_path") ?
62
+ name.singular_route_key.to_sym : part
63
+ end
64
+
65
+ def model_name_for(part)
66
+ klass = part.is_a?(Module) ? part : part.class
67
+ klass.model_name if klass.respond_to?(:model_name)
68
+ end
69
+
70
+ def expand_part(part)
71
+ normalize case part
72
+ when Symbol, String, Hash, Array then part
73
+ when ActiveModel::Naming then part.model_name.collection
74
+ when Class then part.name.underscore
75
+ else expand_part(part.class)
76
+ end
77
+ end
78
+
79
+ def normalize(part)
80
+ case part
81
+ when String
82
+ part.split(?/).map(&:to_sym)
83
+ else part
84
+ end
85
+ end
86
+
87
+ def parts_start_with?(prefix)
88
+ prefix.zip(expanded_parts).all? { _1 == _2 }
89
+ end
90
+ end
91
+ end
92
+
93
+ # def href(*parts, **params)
94
+ # params.merge! parts.extract_options!
95
+ # params.delete(:action) if %i[show destroy index].include? params[:action]
96
+ # url_for([*namespace, *parts, params])
97
+ # end
@@ -0,0 +1,9 @@
1
+ module CafeCar
2
+ module Informable
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def info = CafeCar[:ModelInfo].find(self)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,25 @@
1
+ module CafeCar
2
+ class InputBuilder
3
+ attr_reader :form, :method, :as
4
+
5
+ delegate :object, :object_name, to: :form
6
+
7
+ def initialize(method:, form:, **options, &block)
8
+ @method = method
9
+ @form = form
10
+ @options = options
11
+ @block = block
12
+ end
13
+
14
+ def type
15
+ @type ||=
16
+ object.type_for_attribute(@method)&.type ||
17
+ reflect_on(method)&.macro
18
+ end
19
+
20
+ def placeholder
21
+ I18n.t(method, scope: [ :helpers, :placeholder, object_name ], raise: true).presence
22
+ rescue I18n::MissingTranslationData => _
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,6 @@
1
+ module CafeCar
2
+ module Inputs
3
+ class AssociationBuilder < InputBuilder
4
+ end
5
+ end
6
+ end