katalyst-koi 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
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