plok 0.2.12 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/jquery-ui.autocomplete.html.js +40 -0
  3. data/app/assets/javascripts/plok/searchable.js +26 -0
  4. data/app/assets/javascripts/plok/sidebar.js +66 -0
  5. data/app/assets/stylesheets/plok/_autocomplete.scss +43 -0
  6. data/app/assets/stylesheets/plok/_sidebar.scss +86 -0
  7. data/app/assets/stylesheets/plok/_sidebar_compact.scss +66 -0
  8. data/app/models/concerns/plok/searchable.rb +96 -0
  9. data/app/models/queued_task.rb +7 -0
  10. data/app/models/search_index.rb +5 -0
  11. data/app/models/search_module.rb +11 -0
  12. data/config/initializers/module.rb +17 -0
  13. data/db/migrate/20220923162300_create_search_indices.rb +19 -0
  14. data/db/migrate/20220923164100_create_search_modules.rb +16 -0
  15. data/lib/generators/plok/sidebar/USAGE +8 -0
  16. data/lib/generators/plok/sidebar/sidebar_generator.rb +114 -0
  17. data/lib/generators/plok/sidebar/templates/_menu_item.html.erb +16 -0
  18. data/lib/generators/plok/sidebar/templates/_menu_items.html.erb +9 -0
  19. data/lib/generators/plok/sidebar/templates/_offcanvas_menu.html.erb +11 -0
  20. data/lib/generators/plok/sidebar/templates/_wrapper.html.erb +19 -0
  21. data/lib/plok/engine.rb +5 -2
  22. data/lib/plok/search/backend.rb +22 -0
  23. data/lib/plok/search/base.rb +71 -0
  24. data/lib/plok/search/result_objects/base.rb +74 -0
  25. data/lib/plok/search/term.rb +23 -0
  26. data/lib/plok/version.rb +1 -1
  27. data/lib/tasks/plok_tasks.rake +11 -4
  28. data/spec/factories/search_indices.rb +6 -0
  29. data/spec/factories/search_modules.rb +5 -0
  30. data/spec/support/fake_ar_model.rb +95 -0
  31. data/spec/support/plok/searchable.rb +100 -0
  32. metadata +29 -4
  33. data/config/plok.yml +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4dd177c85c560cc54c99970b9f7bba447ac49b407edd4f92c3bf19eba003a4d6
4
- data.tar.gz: 1567126c937dc4cdaa090c3617a091f63980da34d09452250d7d76518c48d80d
3
+ metadata.gz: caeb408fa4077fd24c19a153f273eb83f21d1da02a601035838e054080b80f85
4
+ data.tar.gz: e9ebfd53e89fdb4adddfb9498e2c7411ce994caf53039e0136459e90dd4a838e
5
5
  SHA512:
6
- metadata.gz: 8d6f225251275ffe58c79f2b812647de21fd5b7522da704901bb23b30906f5137d7a4c5160ab4ef97be1ce4669c1b3258f9366279746171599ae17148d732f0d
7
- data.tar.gz: e0043cc3f281b3e658b653823b0e338c78221b7f4797d8e51e3485206de84bdd02ed6b002b0e490f5609bc8e2322369ca5cdfbfcc30922ea990722a484d44cca
6
+ metadata.gz: 728e58a7153d8bb7bc4e19fde5b676ebec49d671b93d724b0c895d261e07e77bd7c6c347bf97c0dbf6dbc80e9c510e0c3f0bdf161713cf38b407a758f3da6ad0
7
+ data.tar.gz: d14e3d051253c34406c8736d4785b2ea7a1fba11f9e1dc46302b4ace6965d56f599b847ea604838aef90822595b7a526bcf18958f1e86a395e6a39a0c186cb18
@@ -0,0 +1,40 @@
1
+ /*
2
+ * jQuery UI Autocomplete HTML Extension
3
+ *
4
+ * Copyright 2010, Scott González (http://scottgonzalez.com)
5
+ * Dual licensed under the MIT or GPL Version 2 licenses.
6
+ *
7
+ * http://github.com/scottgonzalez/jquery-ui-extensions
8
+ */
9
+ (function( $ ) {
10
+
11
+ var proto = $.ui.autocomplete.prototype,
12
+ initSource = proto._initSource;
13
+
14
+ function filter( array, term ) {
15
+ var matcher = new RegExp( $.ui.autocomplete.escapeRegex(term), "i" );
16
+ return $.grep( array, function(value) {
17
+ return matcher.test( $( "<div>" ).html( value.label || value.value || value ).text() );
18
+ });
19
+ }
20
+
21
+ $.extend( proto, {
22
+ _initSource: function() {
23
+ if ( this.options.html && $.isArray(this.options.source) ) {
24
+ this.source = function( request, response ) {
25
+ response( filter( this.options.source, request.term ) );
26
+ };
27
+ } else {
28
+ initSource.call( this );
29
+ }
30
+ },
31
+
32
+ _renderItem: function( ul, item) {
33
+ return $( "<li></li>" )
34
+ .data( "item.autocomplete", item )
35
+ .append( $( "<a></a>" )[ this.options.html ? "html" : "text" ]( item.label ) )
36
+ .appendTo( ul );
37
+ }
38
+ });
39
+
40
+ })( jQuery );
@@ -0,0 +1,26 @@
1
+ var search = search || {
2
+ init: function() {
3
+ this.target().autocomplete({
4
+ minLength: 2,
5
+ source: search.target().parents('form').attr('action'),
6
+ select: search.select,
7
+ html: true
8
+ }).on('keypress', this.keypress_listener);
9
+ },
10
+
11
+ keypress_listener: function(e) {
12
+ var code = (e.keyCode ? e.keyCode : e.which);
13
+ if(code == 13) return false;
14
+ },
15
+
16
+ select: function(event, ui) {
17
+ window.location = ui.item.value;
18
+ return false;
19
+ },
20
+
21
+ target: function() {
22
+ return $('#search input');
23
+ }
24
+ };
25
+
26
+ $(function(){ search.init(); });
@@ -0,0 +1,66 @@
1
+ var sidebar = {
2
+ init: () => {
3
+ sidebar.bind_mouse_events();
4
+ $('#hamburger').on('click', sidebar.toggle_hamburger_click_listener)
5
+ $(document).on('click', sidebar.document_click_listener)
6
+ },
7
+
8
+ bind_mouse_events: () => {
9
+ $('.wrapper.compact ul li.top-level')
10
+ .on('mouseenter', sidebar.list_item_mouseenter_listener)
11
+ .on('mouseleave', sidebar.list_item_mouseleave_listener)
12
+ },
13
+
14
+ close_all_submenus: () => {
15
+ $('[id^="nav-"]').each((_i, item) => { $(item).removeClass('show') })
16
+ $('a.top-level-anchor').each((_i, item) => { $(item).removeClass('bg-secondary') })
17
+ },
18
+
19
+ document_click_listener: (e) => {
20
+ if($('.compact:visible').length == 0) return
21
+ if($(e.target).closest('.sidebar-menu').length === 0)
22
+ sidebar.close_all_submenus();
23
+ },
24
+
25
+ list_item_mouseenter_listener: (e) => {
26
+ sidebar.list_item_mouseleave_listener(e)
27
+
28
+ let anchor = $(e.target)
29
+ if(!anchor.hasClass('top-level-anchor')) return
30
+ anchor.addClass('bg-secondary')
31
+ $(anchor.attr('href')).addClass('show')
32
+ },
33
+
34
+ list_item_mouseleave_listener: (e) => {
35
+ let anchor = $(e.target)
36
+ if(!anchor.hasClass('top-level-anchor')) return
37
+ sidebar.close_all_submenus();
38
+ },
39
+
40
+ toggle_hamburger_click_listener: e => {
41
+ e.preventDefault()
42
+ $('.wrapper').toggleClass('compact')
43
+
44
+ $('[id^="nav-"]').each((index, item) => {
45
+ if($('.wrapper').hasClass('compact')) {
46
+ $(item).removeClass('show')
47
+ }
48
+ })
49
+
50
+ if($('.wrapper').hasClass('compact')) {
51
+ sidebar.bind_mouse_events()
52
+ $('.sidebar-menu').removeClass('overflow-auto')
53
+ } else {
54
+ sidebar.unbind_mouse_events()
55
+ $('.sidebar-menu').addClass('overflow-auto')
56
+ }
57
+ },
58
+
59
+ unbind_mouse_events: () => {
60
+ $('.wrapper ul li.top-level')
61
+ .off('mouseenter')
62
+ .off('mouseleave')
63
+ }
64
+ }
65
+
66
+ $(() => { sidebar.init() })
@@ -0,0 +1,43 @@
1
+ .ui-autocomplete {
2
+ width: 24rem !important;
3
+ padding: 0;
4
+ z-index: 9999;
5
+ filter: drop-shadow(5px 5px 5px #666);
6
+ }
7
+
8
+ // Shows up during autocompletion sometimes.
9
+ .ui-helper-hidden-accessible {
10
+ display: none;
11
+ }
12
+
13
+ li.ui-menu-item {
14
+ list-style: none;
15
+ cursor: pointer;
16
+ border-bottom: 1px solid #ccc;
17
+ padding: 5px;
18
+
19
+ &:last-child {
20
+ border: none;
21
+ margin-bottom: 0;
22
+ padding-bottom: 0;
23
+ }
24
+
25
+ &:hover {
26
+ background-color: $primary;
27
+ color: white !important;
28
+ }
29
+
30
+ a.ui-menu-item-wrapper {
31
+ border: none;
32
+
33
+ &.ui-state-active {
34
+ background: none;
35
+ color: inherit;
36
+ text-decoration: none;
37
+
38
+ .text-muted {
39
+ color: white !important;
40
+ }
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,86 @@
1
+ .sidebar-menu {
2
+ bottom: 0;
3
+ position: fixed;
4
+ top: 0;
5
+ width: var(--sidebar-width);
6
+ transition: .5s;
7
+ z-index: 10;
8
+
9
+ a.logo {
10
+ display: block;
11
+ letter-spacing: 0.1em;
12
+ line-height: 45px;
13
+ text-decoration: none;
14
+ text-transform: uppercase;
15
+ }
16
+
17
+ a.home-icon {
18
+ display: none !important;
19
+ }
20
+
21
+ ul {
22
+ list-style-type: none;
23
+ padding-left: 0;
24
+
25
+ li {
26
+ cursor: pointer;
27
+
28
+ a {
29
+ color: #fff; // $color-contrast-light from the Bootstrap gem.
30
+ display: block;
31
+ font-size: 1rem;
32
+ padding: 10px;
33
+ padding-left: var(--sidebar-menu-item-left-margin);
34
+ text-decoration: none !important;
35
+
36
+ &:hover {
37
+ background-color: #6c757d; // $secondary from the Bootstrap gem.
38
+ }
39
+ }
40
+
41
+ &.top-level {
42
+ letter-spacing: .05em;
43
+ position: relative;
44
+
45
+ .fa {
46
+ font-size: 14px;
47
+ width: var(--sidebar-icon-width);
48
+ }
49
+
50
+ .text {
51
+ cursor: default;
52
+ font-size: 0.8rem;
53
+ padding: 16px 0 12px 0;
54
+ pointer-events: none;
55
+ text-transform: uppercase;
56
+ }
57
+
58
+ div[id^=nav-] {
59
+ h4 {
60
+ display: none;
61
+ }
62
+
63
+ ul li {
64
+ position: relative;
65
+
66
+ &::before {
67
+ content: '\25A1';
68
+ font-size: 0.4rem;
69
+ left: calc(var(--sidebar-menu-item-left-margin) + 15px);
70
+ position: absolute;
71
+ top: 15px;
72
+ }
73
+
74
+ a {
75
+ font-size: 0.8rem;
76
+ padding-left: calc(var(--sidebar-menu-item-left-margin) + var(--sidebar-icon-width) + 10px);
77
+ text-transform: uppercase;
78
+ }
79
+ }
80
+ }
81
+
82
+ }
83
+ }
84
+ }
85
+
86
+ }
@@ -0,0 +1,66 @@
1
+ .wrapper.compact {
2
+ .content {
3
+ margin-left: var(--sidebar-compact-width);
4
+
5
+ @include media-breakpoint-down(md) {
6
+ margin-left: 0;
7
+ }
8
+ }
9
+
10
+ .sidebar-menu {
11
+ width: var(--sidebar-compact-width);
12
+
13
+ @include media-breakpoint-down(md) {
14
+ width: 0;
15
+ }
16
+
17
+ a.logo {
18
+ display: none !important;
19
+ }
20
+
21
+ a.home-icon {
22
+ display: block !important;
23
+ }
24
+
25
+ ul li.top-level {
26
+ > a { // The icons in the compact view.
27
+ padding: 10px 0;
28
+ text-align: center;
29
+ }
30
+
31
+ h4 {
32
+ cursor: default;
33
+ display: inline-block;
34
+ font-size: .9rem;
35
+ padding: 13px 0 0 5px;
36
+ vertical-align: middle;
37
+ text-transform: uppercase;
38
+ }
39
+
40
+ ul li {
41
+ &::before {
42
+ left: calc(var(--sidebar-icon-width) - 5px);
43
+ }
44
+
45
+ a {
46
+ padding-left: calc(var(--sidebar-icon-width) + 10px);
47
+
48
+ &:hover {
49
+ background-color: $primary;
50
+ }
51
+ }
52
+ }
53
+
54
+ .fa { font-size: 20px; }
55
+ .text { display: none; }
56
+
57
+ .collapse {
58
+ background-color: $secondary;
59
+ left: var(--sidebar-compact-width);
60
+ position: absolute;
61
+ top: 0;
62
+ width: 240px;
63
+ }
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,96 @@
1
+ module Plok::Searchable
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ has_many :search_indices, as: :searchable, dependent: :destroy
6
+
7
+ # The after_save block creates or saves indices for every indicated
8
+ # searchable field. Takes both translations and flexible content into
9
+ # account.
10
+ #
11
+ # Translation support was relatively painless, but FlexibleContent
12
+ # required more thought. See #save_flexible_content_indices!
13
+ after_save do
14
+ trigger_indices_save!
15
+ end
16
+
17
+ def trigger_indices_save!
18
+ self.class.searchable_fields_list.each do |key|
19
+ if key == :flexible_content
20
+ save_flexible_content_search_indices! && next
21
+ end
22
+
23
+ if respond_to?(:translatable?) && self.class.translatable_fields_list.include?(key)
24
+ save_translatable_search_index!(key) && next
25
+ end
26
+
27
+ save_search_index!(key)
28
+ end
29
+ end
30
+
31
+ # I save all ContentText#content values as indices linked to the parent
32
+ # object. The Plok::Search::Base#indices method already groups by
33
+ # searchable resource, so there are never any duplicates.
34
+ # Only additional matches for the searchable because it takes
35
+ # ContentText#content sources into account.
36
+ #
37
+ # ContentColumn and ContentText have after_{save,destroy} callbacks
38
+ # to help facilitate searchable management. Note that said code was
39
+ # initially present in this class, and it was such a mess that it became
40
+ # unpractical to maintain.
41
+ def save_flexible_content_search_indices!
42
+ content_rows.each do |row|
43
+ row.columns.each do |column|
44
+ next unless column.content.is_a?(ContentText)
45
+ key = "flexible_content:#{column.content_id}"
46
+ save_index!(key, value: column.content.content, locale: row.locale)
47
+ end
48
+ end
49
+ end
50
+
51
+ def save_index!(key, value: nil, locale: nil)
52
+ value = read_attribute(key) if value.blank? && respond_to?(key)
53
+ return if value.blank?
54
+
55
+ search_indices
56
+ .find_or_create_by!(name: key, locale: locale)
57
+ .update_column(value: value)
58
+ end
59
+
60
+ # This exists so we can use #save_search_index! as the main method
61
+ # to override, and then be able to call #save_index! in the overridden
62
+ # method to accommodate further defaults.
63
+ def save_search_index!(key)
64
+ value = read_attribute(key)
65
+ save_index!(key, value: value)
66
+ end
67
+
68
+ def save_translatable_search_index!(key)
69
+ # TODO: locales can't be hardcoded
70
+ %w(nl fr).each do |locale|
71
+ value = translation(locale.to_sym).send(key)
72
+ save_index!(key, value: value, locale: locale)
73
+ end
74
+ end
75
+
76
+ def searchable?
77
+ true
78
+ end
79
+ end
80
+
81
+ module ClassMethods
82
+ def searchable_field(key)
83
+ unless searchable_fields_list.include?(key.to_sym)
84
+ searchable_fields_list << key.to_sym
85
+ end
86
+ end
87
+
88
+ def searchable_fields(*args)
89
+ args.each { |key| searchable_field(key) }
90
+ end
91
+
92
+ def searchable_fields_list
93
+ @searchable_fields_list ||= []
94
+ end
95
+ end
96
+ end
@@ -55,6 +55,13 @@ class QueuedTask < ActiveRecord::Base
55
55
  destroy
56
56
  end
57
57
 
58
+ # You want this separate from #unlock so the ensure block in #process! does
59
+ # not keep resetting attempts when it shouldn't. This should be called
60
+ # from the controller where you manually unlock tasks in your backend.
61
+ def reset_attempts!
62
+ update_column(:attempts, 0)
63
+ end
64
+
58
65
  def increase_attempts!
59
66
  update_column(:attempts, attempts + 1)
60
67
  end
@@ -0,0 +1,5 @@
1
+ class SearchIndex < ActiveRecord::Base
2
+ belongs_to :searchable, polymorphic: true
3
+
4
+ validates :name, presence: true
5
+ end
@@ -0,0 +1,11 @@
1
+ class SearchModule < ActiveRecord::Base
2
+ scope :weighted, -> { order('weight DESC') }
3
+
4
+ validates :name, presence: true
5
+
6
+ def indices
7
+ SearchIndex
8
+ .joins('INNER JOIN search_modules ON search_indices.searchable_type = search_modules.name')
9
+ .where('search_modules.name = ?', name)
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ class Module
2
+ def takes(*arg_names)
3
+ define_method(:initialize) do |*arg_values|
4
+ arg_names.zip(arg_values).each do |name, value|
5
+ if name.is_a?(Hash)
6
+ name, default_value = name.to_a.flatten
7
+ value = default_value if value.blank?
8
+ end
9
+
10
+ instance_variable_set(:"@#{name}", value)
11
+ singleton_class.instance_eval { attr_reader name.to_sym }
12
+ end
13
+ end
14
+ end
15
+
16
+ alias_method :let, :define_method
17
+ end
@@ -0,0 +1,19 @@
1
+ # This migration originally comes from udongo_engine
2
+ class CreateSearchIndices < ActiveRecord::Migration[6.1]
3
+ def change
4
+ return if table_exists?(:search_indices)
5
+
6
+ create_table :search_indices do |t|
7
+ t.string :searchable_type
8
+ t.integer :searchable_id
9
+ t.string :locale, index: true
10
+ t.string :name
11
+ t.text :value
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :search_indices, [:searchable_type, :searchable_id]
17
+ add_index :search_indices, [:locale, :name]
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ # This migration originally comes from udongo_engine
2
+ class CreateSearchModules < ActiveRecord::Migration[6.1]
3
+ def change
4
+ return if table_exists?(:search_modules)
5
+
6
+ create_table :search_modules do |t|
7
+ t.string :name
8
+ t.boolean :searchable
9
+ t.integer :weight
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :search_modules, [:name, :searchable]
15
+ end
16
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Explain the generator
3
+
4
+ Example:
5
+ bin/rails generate plok/sidebar
6
+
7
+ This will create:
8
+ what/will/it/create
@@ -0,0 +1,114 @@
1
+ require 'rails/generators/base'
2
+
3
+ class Plok::SidebarGenerator < Rails::Generators::Base
4
+ source_root File.expand_path('templates', __dir__)
5
+ class_option :css_framework, type: :string, default: 'bs5'
6
+
7
+ def install
8
+ copy_sidebar_files('wrapper', 'menu_items', 'menu_item', 'offcanvas_menu')
9
+ add_scss_imports_to_application
10
+ add_js_imports_to_application
11
+ inject_wrapper_block_into_application_layout
12
+ say("\nAll done! Remember to reboot your server so the new assets can load.\n\n")
13
+ end
14
+
15
+ private
16
+
17
+ def add_js_imports_to_application
18
+ if application_file(:js)
19
+ unless file_contains?(application_file(:js), /\/\/= require plok\/sidebar/)
20
+ append_to_file application_file(:js), "//= require plok/sidebar"
21
+ end
22
+ else
23
+ say("\nWARNING: No suitable application.js file found.\n")
24
+ say("Please add the following import to your backend application.js file:\n\n")
25
+ say("//= require plok/sidebar\n\n")
26
+ end
27
+ end
28
+
29
+ def add_scss_imports_to_application
30
+ if application_file(:scss)
31
+ unless file_contains?(application_file(:scss), /@import 'plok\/sidebar'/)
32
+ append_to_file application_file(:scss), "@import 'plok/sidebar';\n"
33
+ end
34
+
35
+ unless file_contains?(application_file(:scss), /@import 'plok\/sidebar_compact'/)
36
+ append_to_file application_file(:scss), "@import 'plok/sidebar_compact';\n"
37
+ end
38
+ else
39
+ say("\nWARNING: No suitable application.scss file found.\n")
40
+ say("Please add the following imports to your backend application.scss file:\n\n")
41
+ say("@import 'plok/sidebar';\n")
42
+ say("@import 'plok/sidebar_compact';\n")
43
+ end
44
+ end
45
+
46
+ def inject_wrapper_block_into_application_layout
47
+ # The sidebar wrapper already exists, so stop here.
48
+ return if file_contains?(application_layout_file, /sidebar\/wrapper/)
49
+
50
+ # The wrapper is missing, but we *need* a suitable spot for a closing tag.
51
+ unless file_contains?(application_layout_file, /yield\(:javascripts_early\)/)
52
+ say("\nWARNING: The generator could not inject the sidebar wrapper.\n")
53
+ say("You will have to wrap your backend application markup in this block:\n\n")
54
+ say("# #{application_layout_file}\n")
55
+ say("<%= render 'backend/#{options.css_framework}/sidebar/wrapper', brand_name: '#{app_name}' do %>\n")
56
+ say(" # ...your backend application markup here...\n")
57
+ say("<% end %>\n")
58
+ return
59
+ end
60
+
61
+ gsub_file(
62
+ application_layout_file,
63
+ /<body(.*)>\n/,
64
+ "<body\\1>\n <%= render 'backend/#{options.css_framework}/sidebar/wrapper', brand_name: '#{app_name}' do %>\n"
65
+ )
66
+
67
+ gsub_file(
68
+ application_layout_file,
69
+ /\n(.*)<%= yield\(:javascripts_early\) %>/,
70
+ " <% end %>\n\n\\1<%= yield(:javascripts_early) %>"
71
+ )
72
+ end
73
+
74
+ def app_name
75
+ Rails.application.class.name.split('::').first
76
+ end
77
+
78
+ def copy_sidebar_files(*partials)
79
+ partials.each do |partial_name|
80
+ copy_file "_#{partial_name}.html.erb", sidebar_partial_path(partial_name)
81
+ end
82
+ end
83
+
84
+ def sidebar_partial_path(partial_name)
85
+ "app/views/backend/#{options.css_framework}/sidebar/_#{partial_name}.html.erb"
86
+ end
87
+
88
+ def file_contains?(file, content)
89
+ File.readlines(file).grep(content).any?
90
+ end
91
+
92
+ def application_layout_file
93
+ if File.exists?("app/views/layouts/backend/#{options.css_framework}/application.html.erb")
94
+ return "app/views/layouts/backend/#{options.css_framework}/application.html.erb"
95
+ end
96
+
97
+ if File.exists?('app/views/layouts/backend/application.html.erb')
98
+ return 'app/views/layouts/backend/application.html.erb'
99
+ end
100
+ end
101
+
102
+ def application_file(type)
103
+ namespace = 'stylesheets'
104
+ namespace = 'javascripts' if type.to_s == 'js'
105
+
106
+ if File.exists?("app/assets/#{namespace}/backend/#{options.css_framework}/application.#{type}")
107
+ return "app/assets/#{namespace}/backend/#{options.css_framework}/application.#{type}"
108
+ end
109
+
110
+ if File.exists?("app/assets/#{namespace}/backend/application.#{type}")
111
+ return "app/assets/#{namespace}/backend/application.#{type}"
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,16 @@
1
+ <% lbl ||= t("b.#{name}") %>
2
+ <% href ||= defined?(path) ? path : "#nav-#{name}" %>
3
+
4
+ <%= content_tag :li, class: 'top-level' do %>
5
+ <%= link_to href, class: 'top-level-anchor link-light', data: { bs_toggle: (defined?(href) ? 'tooltip' : 'collapse') }, title: lbl do %>
6
+ <%= fa_icon icon, class: 'fa-fw' %>
7
+ <%= content_tag :span, lbl, class: 'text' %>
8
+ <% end %>
9
+
10
+ <% if defined?(path) %>
11
+ <%= content_tag :div, id: path, class: 'collapse' do %>
12
+ <h4><%= lbl %></h4>
13
+ <%= yield %>
14
+ <% end %>
15
+ <% end %>
16
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <ul class="justify-content-center">
2
+ <%= render 'backend/bs5/sidebar/menu_item', name: 'admins', icon: 'user-secret-o' do %>
3
+ <ul>
4
+ <%= content_tag :li do %>
5
+ <%= link_to t('b.overview'), backend_admins_path %>
6
+ <% end %>
7
+ </ul>
8
+ <% end %>
9
+ </ul>
@@ -0,0 +1,11 @@
1
+ <div class="offcanvas offcanvas-start bg-dark text-light" tabindex="-1" id="navigation-offcanvas">
2
+ <div class="offcanvas-header">
3
+ <h5 class="offcanvas-title"><%= brand_name %></h5>
4
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
5
+ </div>
6
+ <div class="offcanvas-body">
7
+ <div class="sidebar-menu position-static">
8
+ <%= render 'backend/bs5/sidebar/menu_items' %>
9
+ </div>
10
+ </div>
11
+ </div>
@@ -0,0 +1,19 @@
1
+ <div class="wrapper">
2
+ <div class="sidebar-menu text-light bg-dark overflow-auto">
3
+ <%= link_to backend_path, class: 'text-center navbar-brand logo text-light me-0' do %>
4
+ <%= brand_name %>
5
+ <% end %>
6
+
7
+ <%= link_to fa_icon(:'home'), backend_path, class: 'text-center navbar-brand home-icon text-light fs-2 me-0' %>
8
+
9
+ <nav class="mt-3">
10
+ <%= render 'backend/bs5/sidebar/menu_items' %>
11
+ </nav>
12
+ </div>
13
+
14
+ <div class="content">
15
+ <%= yield %>
16
+ </div>
17
+ </div>
18
+
19
+ <%= render 'backend/bs5/sidebar/offcanvas_menu', brand_name: brand_name %>
data/lib/plok/engine.rb CHANGED
@@ -19,14 +19,17 @@ module Plok
19
19
  g.test_framework :rspec
20
20
  end
21
21
 
22
- def class_exists?(class_name)
22
+ # Autoload classes from the lib dir
23
+ config.autoload_paths << File.expand_path('../..', __FILE__)
24
+
25
+ def self.class_exists?(class_name)
23
26
  klass = Module.const_get(class_name.to_s)
24
27
  klass.is_a?(Class)
25
28
  rescue NameError
26
29
  return false
27
30
  end
28
31
 
29
- def module_exists?(module_name)
32
+ def self.module_exists?(module_name)
30
33
  # By casting to a string and making a constant, we can assume module_name
31
34
  # can be either one without it being a problem.
32
35
  module_name.to_s.constantize.is_a?(Module)
@@ -0,0 +1,22 @@
1
+ # Due to the load order of classes, Backend precedes the required Base class.
2
+ require_relative 'base'
3
+
4
+ module Plok::Search
5
+ # The goal of this class is to provide a manipulated version of the filtered
6
+ # index data that we can use in the result set of an autocomplete-triggered
7
+ # search query. See Plok::Search::Base for more information on how this
8
+ # search functionality is designed.
9
+ class Backend < Plok::Search::Base
10
+ # This translates the filtered indices into meaningful result objects.
11
+ # These require a { label: ... value: ... } to accommodate jquery-ui.
12
+ #
13
+ # Note that the result_object#url method is defined in
14
+ # Plok::Search::ResultObjects::Backend::Page.
15
+ def search
16
+ indices.map do |index|
17
+ result = result_object(index)
18
+ { label: result.build_html, value: result.url }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,71 @@
1
+ module Plok::Search
2
+ # The goal of the Base class is to filter our indices on the given search
3
+ # term. Further manipulation of individual index data into a meaningful
4
+ # result set (think autocomplete results) is done by extending this class.
5
+ #
6
+ # Examples of class extensions could be:
7
+ # Plok::Search::Backend - included in Plok
8
+ # Plok::Search::Frontend
9
+ # Plok::Search::Api
10
+ #
11
+ # The primary benefit in having these namespaced search interfaces is to
12
+ # provide a way for the developer to have different result objects for
13
+ # each resource.
14
+ #
15
+ # Example #1: A search request for a specific Page instance in the frontend
16
+ # will typically return a link to said page. However, in search requests made
17
+ # in the backend for the same Page instance, you'd expect a link to a form in
18
+ # the backend Page module where you can edit the page's contents.
19
+ #
20
+ # Example #2: Some autocompletes in a frontend namespace might require an
21
+ # image or a price to be included in its body.
22
+ #
23
+ # However these result objects are structured are also up to the developer.
24
+ class Base
25
+ attr_reader :term, :controller
26
+
27
+ def initialize(term, controller: nil, namespace: nil)
28
+ @term = Plok::Search::Term.new(term, controller: controller)
29
+ @controller = controller
30
+ @namespace = namespace
31
+ end
32
+
33
+ def indices
34
+ # Having the searchmodules sorted by weight returns indices in the
35
+ # correct order.
36
+ @indices ||= SearchModule.weighted.inject([]) do |stack, m|
37
+ # The group happens to make sure we end up with just 1 copy of
38
+ # a searchable result. Otherwise matches from both an indexed
39
+ # Page#title and Page#description would be in the result set.
40
+ stack << m.indices
41
+ .where(
42
+ '(searchable_id = ? OR search_indices.value LIKE ?)',
43
+ term.value,
44
+ "%#{term.value}%"
45
+ )
46
+ .group([:searchable_type, :searchable_id])
47
+ end.flatten
48
+ end
49
+
50
+ def namespace
51
+ # This looks daft, but it gives us a foot in the door for when a frontend
52
+ # search is triggered in the backend.
53
+ return @namespace unless @namespace.nil?
54
+ return 'Frontend' if controller.nil?
55
+ controller.class.module_parent.to_s
56
+ end
57
+
58
+ # In order to provide a good result set in a search autocomplete, we have
59
+ # to translate the raw index to a class that makes an index adhere
60
+ # to a certain interface (that can include links).
61
+ def result_object(index)
62
+ klass = "Plok::Search::ResultObjects::#{namespace}::#{index.searchable_type}"
63
+ klass = 'Plok::Search::ResultObjects::Base' unless result_object_exists?(klass)
64
+ klass.constantize.new(index, search_context: self)
65
+ end
66
+
67
+ def result_object_exists?(name)
68
+ Plok::Engine.class_exists?(name) && name.constantize.method_defined?(:build_html)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,74 @@
1
+ module Plok
2
+ class InterfaceNotImplementedError < NoMethodError
3
+ end
4
+
5
+ class Search::ResultObjects::Base
6
+ attr_reader :index, :search_context
7
+
8
+ delegate :searchable, to: :index
9
+
10
+ def initialize(index, search_context: nil)
11
+ @index = index
12
+ @search_context = search_context
13
+ end
14
+
15
+ # Typically, an autocomplete requires 3 things:
16
+ #
17
+ # * A title indicating a resource name.
18
+ # Examples: Page#title, Product#name,...
19
+ # * A truncated summary providing a glimpse of the resource's contents.
20
+ # Examples: Page#subtitle, Product#description,...
21
+ # * A link to the resource.
22
+ # Examples: edit_backend_page_path(37), product_path(37),...
23
+ #
24
+ # However, this seems very restrictive to me. If I narrow down the data
25
+ # a dev can use in an autocomplete, it severely reduces options he/she has
26
+ # in how the autocomplete results look like. Think of autocompletes in a
27
+ # shop that require images or prices to be included in their result bodies.
28
+ #
29
+ # This is why I chose to let ApplicationController.render work around the
30
+ # problem by letting the dev decide how the row should look.
31
+ #
32
+ def build_html
33
+ ApplicationController.render(partial: partial, locals: locals)
34
+ end
35
+
36
+ def hidden?
37
+ searchable.respond_to?(:visible?) && searchable.hidden?
38
+ end
39
+
40
+ def label
41
+ # We want to grab the name of the index from ContentText whenever
42
+ # we're dealing with FlexibleContent stuff.
43
+ if index.name.include?('flexible_content')
44
+ id = index.name.split(':')[1].to_i
45
+ return ContentText.find(id).content
46
+ end
47
+
48
+ searchable.send(index.name)
49
+ end
50
+
51
+ def locals
52
+ { "#{partial_target}": index.searchable, index: index }
53
+ end
54
+
55
+ def partial
56
+ "#{partial_path}/#{partial_target}"
57
+ end
58
+
59
+ def partial_path
60
+ "#{search_context.namespace.to_s.underscore}/search"
61
+ end
62
+
63
+ def partial_target
64
+ index.searchable_type.underscore
65
+ end
66
+
67
+ def unpublished?
68
+ searchable.respond_to?(:published?) && searchable.unpublished?
69
+ end
70
+
71
+ def url
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,23 @@
1
+ module Plok::Search
2
+ class Term
3
+ attr_reader :controller, :string
4
+
5
+ def initialize(string, controller: nil)
6
+ @string = string
7
+ @controller = controller
8
+ end
9
+
10
+ def locale
11
+ return controller.locale if controller.present?
12
+ :nl
13
+ end
14
+
15
+ def valid?
16
+ @string.present?
17
+ end
18
+
19
+ def value
20
+ string
21
+ end
22
+ end
23
+ end
data/lib/plok/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Plok
2
- VERSION = '0.2.12'
2
+ VERSION = '1.0.1'
3
3
  end
@@ -1,4 +1,11 @@
1
- # desc "Explaining what the task does"
2
- # task :plok do
3
- # # Task goes here
4
- # end
1
+ namespace 'plok:search' do
2
+ desc 'Rebuild all search indices'
3
+ task rebuild_indices: :environment do
4
+ SearchIndex.destroy_all
5
+ SearchModule.where(searchable: true).each do |m|
6
+ puts "Rebuilding #{m.name} indices..."
7
+ m.name.constantize.all.each(&:trigger_indices_save!)
8
+ end
9
+ puts "Done."
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ FactoryBot.define do
2
+ factory :search_index do
3
+ locale { 'nl' }
4
+ name { 'foo' }
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ FactoryBot.define do
2
+ factory :search_module do
3
+ name { 'Page' }
4
+ end
5
+ end
@@ -0,0 +1,95 @@
1
+ # This exists so one is able to mock actual ActiveRecord model classes that are
2
+ # not tied to a database. This is useful if you want to write tests for concerns
3
+ # without tying them to project-specific models.
4
+ #
5
+ # * It allows you to not mock simple ActiveRecord DSL like find/find_by/where
6
+ # calls.
7
+ # * Projects using Plok can also use this, provided they call
8
+ # Plok::Engine.load_spec_supports in their spec/rails_helper.rb file.
9
+ # * Even if you're required to mock additional business logic onto your classes,
10
+ # you can just extend from Plok::FakeArModel to fill in gaps.
11
+ #
12
+ # Example:
13
+ #
14
+ # class Product < Plok::FakeArModel
15
+ # def visible?
16
+ # true
17
+ # end
18
+ #
19
+ # def hidden?
20
+ # !visible?
21
+ # end
22
+ # end
23
+ #
24
+ class Plok::FakeArModel
25
+ extend ActiveModel::Naming
26
+ extend ActiveModel::Translation
27
+ include ActiveModel::Validations
28
+ include ActiveModel::Conversion
29
+ include ActiveModel::Dirty
30
+
31
+ def initialize(attributes = {})
32
+ attributes.each do |key, value|
33
+ instance_variable_set(:"@#{key}", value)
34
+ self.class.send(:attr_accessor, key)
35
+ end
36
+ end
37
+
38
+ def self.collection(list)
39
+ @collection = list.to_a.map { |values| new(values) }
40
+ end
41
+
42
+ def self.primary_key
43
+ :id
44
+ end
45
+
46
+ def self.polymorphic_name
47
+ :fake_ar_model
48
+ end
49
+
50
+ def self.current_scope
51
+ end
52
+
53
+ def self.find(id)
54
+ @collection.to_a.detect { |o| o.id == id }
55
+ end
56
+
57
+ def self.find_by(arguments)
58
+ where(arguments)&.first
59
+ end
60
+
61
+ def self.where(arguments = nil)
62
+ @collection.select do |o|
63
+ if arguments.is_a?(Hash)
64
+ !!arguments.detect { |k, v| o.send(k) == v }
65
+ elsif(arguments.is_a?(String))
66
+ # NOTE: This will require intensive tinkering if we ever need it,
67
+ # so lets leave it for now.
68
+ []
69
+ end
70
+ end
71
+ end
72
+
73
+ def destroyed?
74
+ false
75
+ end
76
+
77
+ def new_record?
78
+ true
79
+ end
80
+
81
+ def save(options = {})
82
+ true
83
+ end
84
+
85
+ def _read_attribute(a)
86
+ a
87
+ end
88
+
89
+ private
90
+
91
+ def collection
92
+ self.class.instance_variable_get(:@collection)&.to_a || []
93
+ end
94
+
95
+ end
@@ -0,0 +1,100 @@
1
+ require 'rails_helper'
2
+
3
+ shared_examples_for :searchable do
4
+ let(:klass) { described_class.to_s.underscore.to_sym }
5
+
6
+ describe 'after_save' do
7
+ context 'non-translatable model' do
8
+ before do
9
+ # Because this is a spec/support included through it_behaves_like,
10
+ # we have to mock the entry points of the data used in
11
+ # Plok::Searchable.
12
+ allow(described_class).to receive(:searchable_fields_list) { [:foo] }
13
+ if described_class.respond_to?(:translatable_fields_list)
14
+ allow(described_class).to receive(:translatable_fields_list) { [] }
15
+ end
16
+ described_class.define_method(:foo) { 'bar' }
17
+ end
18
+
19
+ it 'default' do
20
+ subject = build(klass)
21
+ allow(subject).to receive(:foo) { 'bar' }
22
+ expect(subject.search_indices).to eq []
23
+ end
24
+
25
+ # NOTE: See if you can dynamically test the creation of indices on save.
26
+ end
27
+
28
+ if described_class.respond_to?(:translatable_fields)
29
+ before do
30
+ allow(described_class).to receive(:searchable_fields_list) { [:foo] }
31
+ end
32
+
33
+ context 'translatable model' do
34
+ before do
35
+ described_class.translatable_fields :foo, :bar
36
+ end
37
+
38
+ let(:create_subject!) do
39
+ subject = build(klass)
40
+ %w(nl fr).each do |l|
41
+ t = subject.translation(l.to_sym)
42
+ t.foo = "baz #{l}"
43
+ t.bar = 'bak'
44
+ end
45
+ subject.save
46
+ subject
47
+ end
48
+
49
+ it 'does not copy translatable fields that are not in searchable fields' do
50
+ subject = create_subject!
51
+ expect(subject.search_indices.find_by(locale: :nl, name: 'bar')).to be nil
52
+ end
53
+ end
54
+ end
55
+
56
+ if described_class.respond_to?(:content_rows)
57
+ context 'model with flexible_content' do
58
+ let(:subject) { create(klass) }
59
+ let(:content) { create(:content_text, content: 'Lorem ipsum') }
60
+
61
+ before do
62
+ # Because this is a spec/support included through it_behaves_like,
63
+ # we have to mock the entry points of the data used in
64
+ # Plok::Searchable.
65
+ allow(described_class).to receive(:searchable_fields_list) { [:foo] }
66
+ allow_any_subject_of(described_class).to receive(:foo) { 'bar' }
67
+ allow_any_subject_of(Concerns::Storable::Collection).to receive(:foo) { 'bar' }
68
+
69
+ row = create(:content_row, locale: 'nl', rowable: subject)
70
+ row.columns << create(:content_column, content: content)
71
+ subject.content_rows << row
72
+ end
73
+
74
+ it 'saves index when updating searchable subject' do
75
+ subject.save!
76
+ key = "flexible_content:#{content.id}"
77
+ expect(subject.search_indices.find_by(locale: :nl, name: key).value).to eq 'Lorem ipsum'
78
+ end
79
+
80
+ it 'saves index when updating flexible content' do
81
+ subject.save!
82
+ content.content = 'Dolor sit amet'
83
+ content.save!
84
+ key = "flexible_content:#{content.id}"
85
+ expect(subject.search_indices.find_by(locale: :nl, name: key).value).to eq 'Dolor sit amet'
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ it '.respond_to?' do
92
+ expect(described_class).to respond_to(
93
+ :searchable_field, :searchable_fields, :searchable_fields_list
94
+ )
95
+ end
96
+
97
+ it '#respond_to?' do
98
+ expect(subject).to respond_to(:search_indices)
99
+ end
100
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plok
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.12
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Davy Hellemans
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-05-12 00:00:00.000000000 Z
12
+ date: 2022-10-14 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -78,12 +78,21 @@ files:
78
78
  - MIT-LICENSE
79
79
  - Rakefile
80
80
  - app/assets/config/plok_manifest.js
81
+ - app/assets/javascripts/jquery-ui.autocomplete.html.js
82
+ - app/assets/javascripts/plok/searchable.js
83
+ - app/assets/javascripts/plok/sidebar.js
84
+ - app/assets/stylesheets/plok/_autocomplete.scss
85
+ - app/assets/stylesheets/plok/_sidebar.scss
86
+ - app/assets/stylesheets/plok/_sidebar_compact.scss
81
87
  - app/controllers/catch_all_controller.rb
82
88
  - app/controllers/plok/version_controller.rb
83
89
  - app/models/concerns/plok/loggable.rb
90
+ - app/models/concerns/plok/searchable.rb
84
91
  - app/models/log.rb
85
92
  - app/models/queued_task.rb
86
- - config/plok.yml
93
+ - app/models/search_index.rb
94
+ - app/models/search_module.rb
95
+ - config/initializers/module.rb
87
96
  - config/routes.rb
88
97
  - db/migrate/20211008130809_create_plok_logs.rb
89
98
  - db/migrate/20211015141837_create_plok_queued_tasks.rb
@@ -93,13 +102,29 @@ files:
93
102
  - db/migrate/20211203103118_add_file_to_logs.rb
94
103
  - db/migrate/20220512141814_add_log_indexes_to_category_type_and_id.rb
95
104
  - db/migrate/20220512142356_add_index_to_queued_task_weight.rb
105
+ - db/migrate/20220923162300_create_search_indices.rb
106
+ - db/migrate/20220923164100_create_search_modules.rb
107
+ - lib/generators/plok/sidebar/USAGE
108
+ - lib/generators/plok/sidebar/sidebar_generator.rb
109
+ - lib/generators/plok/sidebar/templates/_menu_item.html.erb
110
+ - lib/generators/plok/sidebar/templates/_menu_items.html.erb
111
+ - lib/generators/plok/sidebar/templates/_offcanvas_menu.html.erb
112
+ - lib/generators/plok/sidebar/templates/_wrapper.html.erb
96
113
  - lib/plok.rb
97
114
  - lib/plok/engine.rb
115
+ - lib/plok/search/backend.rb
116
+ - lib/plok/search/base.rb
117
+ - lib/plok/search/result_objects/base.rb
118
+ - lib/plok/search/term.rb
98
119
  - lib/plok/version.rb
99
120
  - lib/tasks/plok_tasks.rake
100
121
  - spec/factories/logs.rb
101
122
  - spec/factories/queued_tasks.rb
123
+ - spec/factories/search_indices.rb
124
+ - spec/factories/search_modules.rb
125
+ - spec/support/fake_ar_model.rb
102
126
  - spec/support/plok/loggable.rb
127
+ - spec/support/plok/searchable.rb
103
128
  homepage: https://plok.blimp.be
104
129
  licenses:
105
130
  - MIT
@@ -120,7 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
120
145
  - !ruby/object:Gem::Version
121
146
  version: '0'
122
147
  requirements: []
123
- rubygems_version: 3.2.28
148
+ rubygems_version: 3.3.9
124
149
  signing_key:
125
150
  specification_version: 4
126
151
  summary: CMS basics
data/config/plok.yml DELETED
@@ -1,9 +0,0 @@
1
- development:
2
- modules:
3
- - flexible_content
4
- - snippets
5
- - settings
6
- - queued_tasks
7
- - email_templates
8
- - pages
9
- - redirects