plok 0.2.12 → 1.0.1

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 (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