katalyst-koi 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +23 -0
  4. data/Upgrade.md +6 -0
  5. data/app/assets/builds/koi/admin.css +1 -0
  6. data/app/assets/builds/koi/nav_items.css +1 -0
  7. data/app/assets/config/koi.js +10 -0
  8. data/app/assets/images/koi/application/chevron-right.svg +10 -0
  9. data/app/assets/images/koi/application/glyphicons-halflings-white.png +0 -0
  10. data/app/assets/images/koi/application/glyphicons-halflings.png +0 -0
  11. data/app/assets/images/koi/application/icon-collapse-down.png +0 -0
  12. data/app/assets/images/koi/application/icon-collapse-up.png +0 -0
  13. data/app/assets/images/koi/application/icon-file-doc.png +0 -0
  14. data/app/assets/images/koi/application/icon-file-img.png +0 -0
  15. data/app/assets/images/koi/application/icon-file-pdf.png +0 -0
  16. data/app/assets/images/koi/application/icon-file-ppt.png +0 -0
  17. data/app/assets/images/koi/application/icon-file-unknown.png +0 -0
  18. data/app/assets/images/koi/application/icon-file-xls.png +0 -0
  19. data/app/assets/images/koi/application/icon-file-zip.png +0 -0
  20. data/app/assets/images/koi/application/icon-form-date-picker.png +0 -0
  21. data/app/assets/images/koi/application/icon-form-error.png +0 -0
  22. data/app/assets/images/koi/application/icon-index-sort-ascending.png +0 -0
  23. data/app/assets/images/koi/application/icon-index-sort-descending.png +0 -0
  24. data/app/assets/images/koi/application/icon-index-sort.png +0 -0
  25. data/app/assets/images/koi/application/icon-index-sortable.png +0 -0
  26. data/app/assets/images/koi/application/icon-menu-cursor.png +0 -0
  27. data/app/assets/images/koi/application/icon-overlay-add.png +0 -0
  28. data/app/assets/images/koi/application/icon-overlay-close.png +0 -0
  29. data/app/assets/images/koi/application/icon-sortable.png +0 -0
  30. data/app/assets/images/koi/application/jcrop.gif +0 -0
  31. data/app/assets/images/koi/application/loading.gif +0 -0
  32. data/app/assets/images/koi/application/select-arrow.svg +3 -0
  33. data/app/assets/images/koi/application/select_arrow.png +0 -0
  34. data/app/assets/images/koi/application/sort-ascending.png +0 -0
  35. data/app/assets/images/koi/application/sort-descending.png +0 -0
  36. data/app/assets/javascripts/koi/admin.js +4 -0
  37. data/app/assets/javascripts/koi/controllers/application.js +11 -0
  38. data/app/assets/javascripts/koi/controllers/document_field_controller.js +26 -0
  39. data/app/assets/javascripts/koi/controllers/file_field_controller.js +143 -0
  40. data/app/assets/javascripts/koi/controllers/flash_controller.js +12 -0
  41. data/app/assets/javascripts/koi/controllers/form_request_submit_controller.js +11 -0
  42. data/app/assets/javascripts/koi/controllers/image_field_controller.js +24 -0
  43. data/app/assets/javascripts/koi/controllers/index.js +6 -0
  44. data/app/assets/javascripts/koi/controllers/index_actions_controller.js +61 -0
  45. data/app/assets/javascripts/koi/controllers/keyboard_controller.js +149 -0
  46. data/app/assets/javascripts/koi/controllers/navigation_controller.js +84 -0
  47. data/app/assets/javascripts/koi/controllers/navigation_toggle_controller.js +7 -0
  48. data/app/assets/javascripts/koi/controllers/show_hide_controller.js +25 -0
  49. data/app/assets/javascripts/koi/controllers/sluggable_controller.js +30 -0
  50. data/app/assets/javascripts/koi/controllers/webauthn_authentication_controller.js +23 -0
  51. data/app/assets/javascripts/koi/controllers/webauthn_registration_controller.js +30 -0
  52. data/app/assets/javascripts/koi/utils/transition.js +220 -0
  53. data/app/assets/stylesheets/koi/admin.scss +27 -0
  54. data/app/assets/stylesheets/koi/base/_button.scss +122 -0
  55. data/app/assets/stylesheets/koi/base/_icon.scss +29 -0
  56. data/app/assets/stylesheets/koi/base/_index.scss +18 -0
  57. data/app/assets/stylesheets/koi/base/_input.scss +13 -0
  58. data/app/assets/stylesheets/koi/base/_link.scss +26 -0
  59. data/app/assets/stylesheets/koi/base/_list.scss +11 -0
  60. data/app/assets/stylesheets/koi/base/_typography.scss +160 -0
  61. data/app/assets/stylesheets/koi/components/_actions-group.scss +7 -0
  62. data/app/assets/stylesheets/koi/components/_image-field.scss +33 -0
  63. data/app/assets/stylesheets/koi/components/_index-actions.scss +69 -0
  64. data/app/assets/stylesheets/koi/components/_index-table.scss +91 -0
  65. data/app/assets/stylesheets/koi/components/_index.scss +6 -0
  66. data/app/assets/stylesheets/koi/components/_item-table.scss +33 -0
  67. data/app/assets/stylesheets/koi/components/_pagy.scss +41 -0
  68. data/app/assets/stylesheets/koi/layouts/_banner.scss +7 -0
  69. data/app/assets/stylesheets/koi/layouts/_content.scss +40 -0
  70. data/app/assets/stylesheets/koi/layouts/_flash.scss +41 -0
  71. data/app/assets/stylesheets/koi/layouts/_header.scss +62 -0
  72. data/app/assets/stylesheets/koi/layouts/_index.scss +48 -0
  73. data/app/assets/stylesheets/koi/layouts/_main.scss +23 -0
  74. data/app/assets/stylesheets/koi/layouts/_navigation.scss +156 -0
  75. data/app/assets/stylesheets/koi/layouts/_stack.scss +13 -0
  76. data/app/assets/stylesheets/koi/pages/_index.scss +1 -0
  77. data/app/assets/stylesheets/koi/pages/_login.scss +40 -0
  78. data/app/assets/stylesheets/koi/themes/_content.scss +5 -0
  79. data/app/assets/stylesheets/koi/themes/_govuk.scss +52 -0
  80. data/app/assets/stylesheets/koi/themes/_index.scss +5 -0
  81. data/app/assets/stylesheets/koi/themes/_kpop.scss +5 -0
  82. data/app/assets/stylesheets/koi/themes/_navigation.scss +5 -0
  83. data/app/assets/stylesheets/koi/themes/_trix.scss +32 -0
  84. data/app/assets/stylesheets/koi/utils/_breakpoints.scss +13 -0
  85. data/app/assets/stylesheets/koi/utils/_hide.scss +11 -0
  86. data/app/assets/stylesheets/koi/utils/_index.scss +2 -0
  87. data/app/assets/stylesheets/koi/utils/_typography.scss +24 -0
  88. data/app/components/koi/header/edit_component.rb +58 -0
  89. data/app/components/koi/header/index_component.rb +23 -0
  90. data/app/components/koi/header/new_component.rb +40 -0
  91. data/app/components/koi/header/show_component.rb +51 -0
  92. data/app/components/koi/header_component.html.erb +16 -0
  93. data/app/components/koi/header_component.rb +28 -0
  94. data/app/components/koi/index_table_component.rb +21 -0
  95. data/app/controllers/admin/admin_users_controller.rb +88 -0
  96. data/app/controllers/admin/application_controller.rb +9 -0
  97. data/app/controllers/admin/caches_controller.rb +11 -0
  98. data/app/controllers/admin/credentials_controller.rb +64 -0
  99. data/app/controllers/admin/dashboards_controller.rb +7 -0
  100. data/app/controllers/admin/sessions_controller.rb +78 -0
  101. data/app/controllers/admin/url_rewrites_controller.rb +87 -0
  102. data/app/controllers/concerns/koi/controller/has_admin_users.rb +49 -0
  103. data/app/controllers/concerns/koi/controller/has_webauthn.rb +45 -0
  104. data/app/controllers/concerns/koi/controller/is_admin_controller.rb +52 -0
  105. data/app/helpers/katalyst/content/editor/errors.rb +21 -0
  106. data/app/helpers/katalyst/navigation/editor/errors.rb +21 -0
  107. data/app/helpers/koi/application_helper.rb +7 -0
  108. data/app/helpers/koi/date_helper.rb +36 -0
  109. data/app/helpers/koi/definition_list_helper.rb +92 -0
  110. data/app/helpers/koi/index_actions_helper.rb +99 -0
  111. data/app/jobs/koi/application_job.rb +6 -0
  112. data/app/mailers/koi/application_mailer.rb +8 -0
  113. data/app/models/admin/credential.rb +14 -0
  114. data/app/models/admin/user.rb +51 -0
  115. data/app/models/application_record.rb +5 -0
  116. data/app/models/concerns/koi/model/archivable.rb +55 -0
  117. data/app/models/url_rewrite.rb +25 -0
  118. data/app/views/admin/admin_users/_admin.html+row.erb +4 -0
  119. data/app/views/admin/admin_users/_authentication.html.erb +15 -0
  120. data/app/views/admin/admin_users/_fields.html.erb +4 -0
  121. data/app/views/admin/admin_users/edit.html.erb +11 -0
  122. data/app/views/admin/admin_users/index.html.erb +9 -0
  123. data/app/views/admin/admin_users/new.html.erb +11 -0
  124. data/app/views/admin/admin_users/show.html.erb +22 -0
  125. data/app/views/admin/credentials/new.html.erb +14 -0
  126. data/app/views/admin/dashboards/show.html.erb +1 -0
  127. data/app/views/admin/sessions/new.html.erb +19 -0
  128. data/app/views/admin/shared/icons/_close.html.erb +8 -0
  129. data/app/views/admin/shared/icons/_cross.html.erb +3 -0
  130. data/app/views/admin/shared/icons/_menu.html.erb +3 -0
  131. data/app/views/admin/shared/icons/_refresh.html.erb +8 -0
  132. data/app/views/admin/url_rewrites/_form_fields.html.erb +3 -0
  133. data/app/views/admin/url_rewrites/_url_rewrite.html+row.erb +7 -0
  134. data/app/views/admin/url_rewrites/edit.html.erb +12 -0
  135. data/app/views/admin/url_rewrites/index.html.erb +10 -0
  136. data/app/views/admin/url_rewrites/new.html.erb +11 -0
  137. data/app/views/admin/url_rewrites/show.html.erb +16 -0
  138. data/app/views/katalyst/content/asides/_aside.html+form.erb +18 -0
  139. data/app/views/katalyst/content/columns/_column.html+form.erb +18 -0
  140. data/app/views/katalyst/content/contents/_content.html+form.erb +20 -0
  141. data/app/views/katalyst/content/figures/_figure.html+form.erb +17 -0
  142. data/app/views/katalyst/content/groups/_group.html+form.erb +18 -0
  143. data/app/views/katalyst/content/items/_item.html+form.erb +18 -0
  144. data/app/views/katalyst/content/sections/_section.html+form.erb +18 -0
  145. data/app/views/katalyst/navigation/items/_button.html.erb +15 -0
  146. data/app/views/katalyst/navigation/items/_heading.html.erb +11 -0
  147. data/app/views/katalyst/navigation/items/_link.html.erb +13 -0
  148. data/app/views/katalyst/navigation/menus/edit.html.erb +12 -0
  149. data/app/views/katalyst/navigation/menus/new.html.erb +9 -0
  150. data/app/views/katalyst/navigation/menus/show.html.erb +18 -0
  151. data/app/views/layouts/koi/_environment.html.erb +4 -0
  152. data/app/views/layouts/koi/_flash.html.erb +8 -0
  153. data/app/views/layouts/koi/_header.html.erb +11 -0
  154. data/app/views/layouts/koi/_navigation.html.erb +13 -0
  155. data/app/views/layouts/koi/_navigation_collapse.html.erb +3 -0
  156. data/app/views/layouts/koi/_navigation_header.html.erb +6 -0
  157. data/app/views/layouts/koi/_navigation_item.html.erb +12 -0
  158. data/app/views/layouts/koi/application.html.erb +59 -0
  159. data/app/views/layouts/koi/login.html.erb +29 -0
  160. data/config/importmap.rb +9 -0
  161. data/config/initializers/flipper.rb +13 -0
  162. data/config/initializers/pagy.rb +1 -0
  163. data/config/initializers/time_formats.rb +5 -0
  164. data/config/locales/koi.en.yml +18 -0
  165. data/config/locales/pagy.en.yml +6 -0
  166. data/config/routes.rb +25 -0
  167. data/db/migrate/20120220130849_devise_create_admins.rb +56 -0
  168. data/db/migrate/20130509235316_add_url_rewriter.rb +13 -0
  169. data/db/migrate/20230213053854_convert_devise_admins_to_rails.rb +7 -0
  170. data/db/migrate/20230412023411_create_admin_user_credentials.rb +20 -0
  171. data/db/migrate/20230531063707_update_admin_users.rb +37 -0
  172. data/db/migrate/20230602033610_add_archived_to_admin_users.rb +7 -0
  173. data/db/seeds.rb +9 -0
  174. data/lib/generators/koi/active_record/active_record_generator.rb +43 -0
  175. data/lib/generators/koi/admin/USAGE +8 -0
  176. data/lib/generators/koi/admin/admin_generator.rb +20 -0
  177. data/lib/generators/koi/admin_controller/USAGE +17 -0
  178. data/lib/generators/koi/admin_controller/admin_controller_generator.rb +51 -0
  179. data/lib/generators/koi/admin_controller/templates/controller.rb.tt +81 -0
  180. data/lib/generators/koi/admin_controller/templates/controller_spec.rb.tt +135 -0
  181. data/lib/generators/koi/admin_route/admin_route_generator.rb +62 -0
  182. data/lib/generators/koi/admin_views/USAGE +12 -0
  183. data/lib/generators/koi/admin_views/admin_views_generator.rb +54 -0
  184. data/lib/generators/koi/admin_views/templates/_fields.html.erb.tt +3 -0
  185. data/lib/generators/koi/admin_views/templates/_record.html+row.erb.tt +10 -0
  186. data/lib/generators/koi/admin_views/templates/edit.html.erb.tt +12 -0
  187. data/lib/generators/koi/admin_views/templates/index.html.erb.tt +7 -0
  188. data/lib/generators/koi/admin_views/templates/new.html.erb.tt +11 -0
  189. data/lib/generators/koi/admin_views/templates/show.html.erb.tt +18 -0
  190. data/lib/govuk_design_system_formbuilder/concerns/file_element.rb +115 -0
  191. data/lib/govuk_design_system_formbuilder/elements/document.rb +59 -0
  192. data/lib/govuk_design_system_formbuilder/elements/image.rb +86 -0
  193. data/lib/katalyst/koi.rb +3 -0
  194. data/lib/koi/caching.rb +15 -0
  195. data/lib/koi/config.rb +11 -0
  196. data/lib/koi/engine.rb +40 -0
  197. data/lib/koi/form_builder.rb +76 -0
  198. data/lib/koi/menu/builder.rb +68 -0
  199. data/lib/koi/menu.rb +46 -0
  200. data/lib/koi/middleware/url_redirect.rb +44 -0
  201. data/lib/koi/release.rb +52 -0
  202. data/lib/koi/version.rb +5 -0
  203. data/lib/koi.rb +37 -0
  204. data/spec/factories/admins.rb +9 -0
  205. data/spec/factories/url_rewrites.rb +9 -0
  206. metadata +430 -0
@@ -0,0 +1 @@
1
+ Pagy::I18n.load({ locale: "en", filepath: Koi::Engine.root.join("config/locales/pagy.en.yml") })
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Time::DATE_FORMATS[:pretty] = ->(time) { time.strftime("%a, %b %e at %l:%M") + time.strftime("%p").downcase }
4
+ Date::DATE_FORMATS[:default] = "%d %b %Y"
5
+ Time::DATE_FORMATS[:default] = "%a, %b %e at %l:%M %p"
@@ -0,0 +1,18 @@
1
+ en:
2
+ time:
3
+ formats:
4
+ default: "%Y-%m-%dT%H:%M:%S.%L%z"
5
+ display: "%e %B %Y, %l:%M%P"
6
+ date:
7
+ formats:
8
+ default: "%Y-%m-%d"
9
+ display: "%e %B %Y"
10
+ koi:
11
+ labels:
12
+ new: New
13
+ search: Search
14
+ helpers:
15
+ hint:
16
+ default:
17
+ document: Must be less than %{max_size}
18
+ image: Must be less than %{max_size}
@@ -0,0 +1,6 @@
1
+ en:
2
+ pagy:
3
+ nav:
4
+ prev: "←  Previous"
5
+ next: "Next  →"
6
+ gap: "…"
data/config/routes.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ namespace :admin do
5
+ resource :session, only: %i[new create destroy]
6
+
7
+ resources :url_rewrites
8
+ resources :admin_users do
9
+ resources :credentials, only: %i[new create destroy]
10
+ end
11
+
12
+ resource :cache, only: %i[destroy]
13
+ resource :dashboard, only: %i[show]
14
+
15
+ root to: redirect("admin/dashboard")
16
+ end
17
+
18
+ scope :admin do
19
+ constraints ->(req) { req.session[:admin_user_id].present? } do
20
+ mount Katalyst::Content::Engine, at: "content"
21
+ mount Katalyst::Navigation::Engine, at: "navigation"
22
+ mount Flipper::UI.app(Flipper) => "flipper" if Object.const_defined?("Flipper::UI")
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DeviseCreateAdmins < ActiveRecord::Migration[4.2]
4
+ # rubocop:disable Metrics/BlockLength
5
+ def change
6
+ create_table(:admins) do |t|
7
+ ## Database authenticatable
8
+ t.string :email, null: false, default: ""
9
+ t.string :encrypted_password, null: false, default: ""
10
+
11
+ ## Recoverable
12
+ t.string :reset_password_token
13
+ t.datetime :reset_password_sent_at
14
+
15
+ ## Rememberable
16
+ t.datetime :remember_created_at
17
+
18
+ ## Trackable
19
+ t.integer :sign_in_count, default: 0
20
+ t.datetime :current_sign_in_at
21
+ t.datetime :last_sign_in_at
22
+ t.string :current_sign_in_ip
23
+ t.string :last_sign_in_ip
24
+
25
+ ## Encryptable
26
+ # t.string :password_salt
27
+
28
+ ## Confirmable
29
+ # t.string :confirmation_token
30
+ # t.datetime :confirmed_at
31
+ # t.datetime :confirmation_sent_at
32
+ # t.string :unconfirmed_email # Only if using reconfirmable
33
+
34
+ ## Lockable
35
+ # t.integer :failed_attempts, :default => 0 # Only if lock strategy is :failed_attempts
36
+ # t.string :unlock_token # Only if unlock strategy is :email or :both
37
+ t.datetime :locked_at
38
+
39
+ ## Token authenticatable
40
+ # t.string :authentication_token
41
+
42
+ t.string :first_name
43
+ t.string :last_name
44
+ t.string :role
45
+
46
+ t.timestamps
47
+ end
48
+
49
+ add_index :admins, :email, unique: true
50
+ add_index :admins, :reset_password_token, unique: true
51
+ # add_index :koi_admins, :confirmation_token, :unique => true
52
+ # add_index :koi_admins, :unlock_token, :unique => true
53
+ # add_index :koi_admins, :authentication_token, :unique => true
54
+ end
55
+ # rubocop:enable Metrics/BlockLength
56
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddUrlRewriter < ActiveRecord::Migration[4.2]
4
+ def change
5
+ create_table :url_rewrites do |t|
6
+ t.text :from
7
+ t.text :to
8
+ t.boolean :active, default: true
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConvertDeviseAdminsToRails < ActiveRecord::Migration[7.0]
4
+ def change
5
+ rename_column :admins, :encrypted_password, :password_digest
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAdminUserCredentials < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :admin_credentials do |t|
6
+ t.string "external_id", index: { unique: true }
7
+ t.references :admin, null: false, foreign_key: true
8
+
9
+ t.string "public_key"
10
+ t.string "nickname"
11
+ t.bigint "sign_count", default: 0, null: false
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ change_table :admins do |t|
17
+ t.column :webauthn_id, :string
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ class UpdateAdminUsers < ActiveRecord::Migration[7.0]
2
+ class Admin < ActiveRecord::Base; end
3
+
4
+ def up
5
+ add_column :admins, :name, :string
6
+
7
+ Admin.all.each do |admin|
8
+ admin.name = "#{admin.first_name} #{admin.last_name}"
9
+ admin.save!
10
+ end
11
+
12
+ remove_column :admins, :first_name, :string
13
+ remove_column :admins, :last_name, :string
14
+ remove_column :admins, :reset_password_token, :string, index: { unique: true }
15
+ remove_column :admins, :reset_password_sent_at, :datetime
16
+ remove_column :admins, :remember_created_at, :datetime
17
+ remove_column :admins, :locked_at, :datetime
18
+ remove_column :admins, :role, :string
19
+ end
20
+
21
+ def down
22
+ add_column :admins, :first_name, :string
23
+ add_column :admins, :last_name, :string
24
+ add_column :admins, :reset_password_token, :string, index: { unique: true }
25
+ add_column :admins, :reset_password_sent_at, :datetime
26
+ add_column :admins, :remember_created_at, :datetime
27
+ add_column :admins, :locked_at, :datetime
28
+ add_column :admins, :role, :string
29
+
30
+ Admin.all.each do |admin|
31
+ admin.first_name, admin.last_name = admin.name.split(' ', 2)
32
+ admin.save!
33
+ end
34
+
35
+ remove_column :admins, :name, :string
36
+ end
37
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddArchivedToAdminUsers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :admins, :archived_at, :datetime
6
+ end
7
+ end
data/db/seeds.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Create a default admin user (only in development)
4
+ if Rails.env.development? && !ENV["CI"]
5
+ Admin::User.create_with(
6
+ name: `id -F`.strip,
7
+ password: "password",
8
+ ).find_or_create_by(email: "#{ENV['USER']}@katalyst.com.au")
9
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record/model/model_generator"
4
+
5
+ module Koi
6
+ class ActiveRecordGenerator < ActiveRecord::Generators::ModelGenerator
7
+ source_root ActiveRecord::Generators::ModelGenerator.source_root
8
+
9
+ def admin_search
10
+ "PgSearch::Model".safe_constantize ? pg_search : sql_search
11
+ end
12
+
13
+ private
14
+
15
+ def pg_search
16
+ insert_into_file "app/models/#{file_name}.rb", after: "class #{class_name} < ApplicationRecord\n" do
17
+ <<~RUBY
18
+ include PgSearch::Model
19
+ RUBY
20
+ end
21
+
22
+ insert_into_file "app/models/#{file_name}.rb", before: "end\n" do
23
+ <<~RUBY
24
+ pg_search :admin_search, against: %i[#{search_fields.join(' ')}], using: { tsearch: { prefix: true } }
25
+ RUBY
26
+ end
27
+ end
28
+
29
+ def sql_search
30
+ insert_into_file "app/models/#{file_name}.rb", before: "end\n" do
31
+ <<~RUBY
32
+ scope :admin_search, ->(query) do
33
+ where("#{search_fields.map { |f| "#{f} LIKE :query" }.join(' OR ')}", query: "%\#{query}%")
34
+ end
35
+ RUBY
36
+ end
37
+ end
38
+
39
+ def search_fields
40
+ attributes.select { |attr| attr.type == :string }.map(&:name)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Generates a model with Koi admin crud
3
+
4
+ Example:
5
+ bin/rails generate koi:admin Test name:string description:text
6
+
7
+ This will create a new model using the rails model generator, then run
8
+ the Koi admin controller generator.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/rails/scaffold/scaffold_generator"
4
+
5
+ module Koi
6
+ class AdminGenerator < Rails::Generators::ScaffoldGenerator
7
+ # Replace the default model generator with our own
8
+ remove_hook_for(:orm)
9
+ hook_for(:orm, in: :koi, as: :admin, default: true)
10
+
11
+ # Disable default controller generation as we do not want to generate public
12
+ # controllers by default
13
+ remove_hook_for(:scaffold_controller)
14
+ remove_hook_for(:resource_route)
15
+
16
+ hook_for :admin_controller, in: :koi, as: :admin, type: :boolean, default: true
17
+
18
+ Rails::Generators::ModelGenerator.hook_for :admin_search, type: :boolean, default: true
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ Description:
2
+ Generates a Koi admin crud for the given model
3
+
4
+ Example:
5
+ bin/rails generate koi:admin_controller Test
6
+
7
+ This will create:
8
+ app/controllers/admin/tests_controller.rb
9
+ app/views/admin/tests/_form_fields.html.erb
10
+ app/views/admin/tests/edit.html.erb
11
+ app/views/admin/tests/index.html.erb
12
+ app/views/admin/tests/new.html.erb
13
+ app/views/admin/tests/show.html.erb
14
+
15
+ This will update:
16
+ config/routes/admin.rb to include `resources :tests` to the :admin namespace
17
+ config/initializers/koi.rb to include "Tests => /admin/tests" in the Modules section
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+ require "rails/generators/resource_helpers"
5
+
6
+ module Koi
7
+ class AdminControllerGenerator < Rails::Generators::NamedBase
8
+ include Rails::Generators::ResourceHelpers
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ check_class_collision prefix: "Admin::", suffix: "Controller"
13
+
14
+ argument :attributes, type: :array, default: [], banner: "field:type field:type"
15
+
16
+ def create_controller_files
17
+ template("controller.rb",
18
+ File.join("app/controllers/admin",
19
+ controller_class_path,
20
+ "#{controller_file_name}_controller.rb"))
21
+ end
22
+
23
+ def create_spec_files
24
+ template("controller_spec.rb",
25
+ File.join("spec/requests/admin",
26
+ controller_class_path,
27
+ "#{controller_file_name}_controller_spec.rb"))
28
+ end
29
+
30
+ hook_for(:admin_views, in: :koi, type: :boolean, default: true)
31
+ hook_for(:admin_route, in: :koi, type: :boolean, default: true)
32
+
33
+ private
34
+
35
+ def permitted_params
36
+ attachments, others = attributes_names.partition { |name| attachments?(name) }
37
+ params = others.map { |name| ":#{name}" }
38
+ params += attachments.map { |name| "#{name}: []" }
39
+ params.join(", ")
40
+ end
41
+
42
+ def attachments?(name)
43
+ attribute = attributes.find { |attr| attr.name == name }
44
+ attribute&.attachments?
45
+ end
46
+
47
+ def search_attribute
48
+ attributes.find { |attr| attr.type == :string }&.name
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class <%= controller_class_name %>Controller < ApplicationController
5
+
6
+ before_action :set_<%= singular_name %>, only: %i[show edit update destroy]
7
+
8
+ def index
9
+ collection = Collection.new.with_params(params).apply(::<%= class_name %>.strict_loading.all)
10
+ table = Koi::IndexTableComponent.new(collection:)
11
+
12
+ respond_to do |format|
13
+ format.turbo_stream { render table } if self_referred?
14
+ format.html { render :index, locals: { table:, collection: } }
15
+ end
16
+ end
17
+
18
+ def show
19
+ render locals: { <%= singular_name %>: @<%= singular_name %> }
20
+ end
21
+
22
+ def new
23
+ render locals: { <%= singular_name %>: ::<%= class_name %>.new }
24
+ end
25
+
26
+ def edit
27
+ render locals: { <%= singular_name %>: @<%= singular_name %> }
28
+ end
29
+
30
+ def create
31
+ @<%= singular_name %> = ::<%= class_name %>.new(<%= singular_name %>_params)
32
+
33
+ if @<%= singular_name %>.save
34
+ redirect_to [:admin, @<%= singular_name %>]
35
+ else
36
+ render :new, locals: { <%= singular_name %>: @<%= singular_name %> }, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ def update
41
+ if @<%= singular_name %>.update(<%= singular_name %>_params)
42
+ redirect_to action: :show
43
+ else
44
+ render :edit, locals: { <%= singular_name %>: @<%= singular_name %> }, status: :unprocessable_entity
45
+ end
46
+ end
47
+
48
+ def destroy
49
+ @<%= singular_name %>.destroy
50
+
51
+ redirect_to action: :index
52
+ end
53
+
54
+ private
55
+
56
+ # Only allow a list of trusted parameters through.
57
+ def <%= "#{singular_table_name}_params" %>
58
+ <%- if attributes_names.empty? -%>
59
+ params.fetch(:<%= singular_table_name %>, {})
60
+ <%- else -%>
61
+ params.require(:<%= singular_table_name %>).permit(<%= permitted_params %>)
62
+ <%- end -%>
63
+ end
64
+
65
+ # Use callbacks to share common setup or constraints between actions.
66
+ def set_<%= singular_table_name %>
67
+ @<%= singular_table_name %> = ::<%= class_name %>.find(params[:id])
68
+ end
69
+
70
+ class Collection < Katalyst::Tables::Collection::Base
71
+ attribute :search, :string
72
+
73
+ config.sorting = :<%= search_attribute %>
74
+ config.paginate = true
75
+
76
+ def filter
77
+ self.items = items.admin_search(search) if search.present?
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe Admin::<%= controller_class_name %>Controller do
6
+ let(:admin) { create(:admin) }
7
+ let(:model) { create(:<%= singular_name %>) }
8
+
9
+ include_context "with admin session"
10
+
11
+ describe "GET /admin/<%= plural_name %>" do
12
+ let(:action) { get polymorphic_path([:admin, <%= class_name %>]) }
13
+
14
+ it_behaves_like "requires admin"
15
+
16
+ it "renders successfully" do
17
+ action
18
+ expect(response).to have_http_status(:success)
19
+ end
20
+
21
+ context "with a large collection" do
22
+ before { create_list(:<%= singular_name %>, 25) }
23
+
24
+ it "paginates the collection" do
25
+ action
26
+ expect(response.body).to have_selector("tbody tr", count: 20)
27
+ end
28
+ end
29
+
30
+ context "with sort parameter" do
31
+ let(:action) { get polymorphic_path([:admin, <%= class_name %>], sort: "<%= search_attribute %> desc") }
32
+
33
+ before do
34
+ create(:<%= singular_name %>, <%= search_attribute %>: "first")
35
+ create(:<%= singular_name %>, <%= search_attribute %>: "second")
36
+ end
37
+
38
+ it "finds first in second place" do
39
+ action
40
+ expect(response.body).to have_selector("tbody tr + tr td", text: "first")
41
+ end
42
+ end
43
+
44
+ context "with search parameter" do
45
+ let(:action) { get polymorphic_path([:admin, <%= class_name %>], search: "first") }
46
+
47
+ before do
48
+ create(:<%= singular_name %>, <%= search_attribute %>: "first")
49
+ create(:<%= singular_name %>, <%= search_attribute %>: "second")
50
+ end
51
+
52
+ it "finds the needle" do
53
+ action
54
+ expect(response.body).to have_selector("table td", text: "first")
55
+ end
56
+
57
+ it "removes the chaff" do
58
+ action
59
+ expect(response.body).not_to have_selector("table td", text: "second")
60
+ end
61
+ end
62
+ end
63
+
64
+ describe "GET /admin/<%= plural_name %>/new" do
65
+ let(:action) { get new_polymorphic_path([:admin, <%= class_name %>]) }
66
+
67
+ it_behaves_like "requires admin"
68
+
69
+ it "renders successfully" do
70
+ action
71
+ expect(response).to have_http_status(:success)
72
+ end
73
+ end
74
+
75
+ describe "POST /admin/<%= plural_name %>" do
76
+ let(:action) { post polymorphic_path([:admin, <%= class_name %>]), params: { <%= singular_name %>: params } }
77
+ let(:params) { attributes_for(:<%= singular_name %>) }
78
+
79
+ it_behaves_like "requires admin"
80
+
81
+ it "renders successfully" do
82
+ action
83
+ expect(response).to redirect_to([:admin, assigns(:<%= singular_name %>)])
84
+ end
85
+
86
+ it "creates a <%= singular_name %>" do
87
+ expect { action }.to change(<%= class_name %>, :count).by(1)
88
+ end
89
+ end
90
+
91
+ describe "GET /admin/<%= plural_name %>/:id" do
92
+ let(:action) { get polymorphic_path([:admin, model]) }
93
+
94
+ it_behaves_like "requires admin"
95
+
96
+ it "renders successfully" do
97
+ action
98
+ expect(response).to have_http_status(:success)
99
+ end
100
+ end
101
+
102
+ describe "GET /admin/<%= plural_name %>/:id/edit" do
103
+ let(:action) { get edit_polymorphic_path([:admin, model]) }
104
+
105
+ it_behaves_like "requires admin"
106
+
107
+ it "renders successfully" do
108
+ action
109
+ expect(response).to have_http_status(:success)
110
+ end
111
+ end
112
+
113
+ describe "PATCH /admin/<%= plural_name %>/:id" do
114
+ let(:action) { patch polymorphic_path([:admin, model]), params: { <%= singular_name %>: params } }
115
+ let(:params) { attributes_for(:<%= singular_name %>) }
116
+
117
+ it_behaves_like "requires admin"
118
+
119
+ it "renders successfully" do
120
+ action
121
+ expect(response).to redirect_to(polymorphic_path([:admin, model]))
122
+ end
123
+ end
124
+
125
+ describe "DELETE /admin/<%= plural_name %>/:id" do
126
+ let(:action) { delete polymorphic_path([:admin, model]) }
127
+
128
+ it_behaves_like "requires admin"
129
+
130
+ it "renders successfully" do
131
+ action
132
+ expect(response).to redirect_to(polymorphic_path([:admin, <%= class_name %>]))
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+ require "rails/generators/resource_helpers"
5
+
6
+ module Koi
7
+ class AdminRouteGenerator < Rails::Generators::NamedBase
8
+ include Rails::Generators::ResourceHelpers
9
+
10
+ def add_route
11
+ route "resources :#{file_name.pluralize}", namespace: regular_class_path
12
+ end
13
+
14
+ def add_navigation
15
+ insert_into_file("config/initializers/koi.rb",
16
+ " \"#{[*regular_class_path.map(&:humanize),
17
+ human_name.pluralize].join(' ')}\" => \"/admin#{route_url}\",\n",
18
+ after: "Koi::Menu.modules = {\n")
19
+ end
20
+
21
+ private
22
+
23
+ # See Rails::Generators::Actions
24
+ # Replaces hard-coded route with admin route file
25
+ def route(routing_code, namespace: nil)
26
+ namespace = Array(namespace)
27
+ namespace_pattern = route_namespace_pattern(namespace)
28
+ routing_code = namespace.reverse.reduce(routing_code) do |code, name|
29
+ "namespace :#{name} do\n#{rebase_indentation(code, 2)}end"
30
+ end
31
+
32
+ log :route, routing_code
33
+
34
+ in_root do
35
+ if (namespace_match = match_file(route_file, namespace_pattern))
36
+ base_indent, *, existing_block_indent = namespace_match.captures.compact.map(&:length)
37
+ existing_line_pattern = /^ {,#{existing_block_indent}}\S.+\n?/
38
+ routing_code = rebase_indentation(routing_code, base_indent + 2).gsub(existing_line_pattern, "")
39
+ namespace_pattern = /#{Regexp.escape namespace_match.to_s}/
40
+ end
41
+
42
+ inject_into_file route_file, routing_code, after: namespace_pattern, verbose: true, force: false
43
+ end
44
+ end
45
+
46
+ # See Rails::Generators::Actions
47
+ # Replaces Routes.draw with namespace :admin as the search term
48
+ def route_namespace_pattern(namespace)
49
+ namespace.each_with_index.reverse_each.reduce(nil) do |pattern, (name, i)|
50
+ cummulative_margin = "\\#{i + 1}[ ]{2}"
51
+ blank_or_indented_line = "^[ ]*\n|^#{cummulative_margin}.*\n"
52
+ "(?:(?:#{blank_or_indented_line})*?^(#{cummulative_margin})namespace :#{name} do\n#{pattern})?"
53
+ end.then do |pattern|
54
+ /^( *)namespace :admin do\n#{pattern}/
55
+ end
56
+ end
57
+
58
+ def route_file
59
+ "config/routes/admin.rb"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,12 @@
1
+ Description:
2
+ Generates a Koi admin crud views for the given model
3
+
4
+ Example:
5
+ bin/rails generate koi:admin_views Test
6
+
7
+ This will create:
8
+ app/views/admin/tests/_fields.html.erb
9
+ app/views/admin/tests/edit.html.erb
10
+ app/views/admin/tests/index.html.erb
11
+ app/views/admin/tests/new.html.erb
12
+ app/views/admin/tests/show.html.erb