avo 1.20.1 → 1.20.2.pre.1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of avo might be problematic. Click here for more details.

Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -1
  3. data/Gemfile.lock +7 -3
  4. data/app/assets/builds/avo.css +8606 -0
  5. data/app/assets/builds/avo.js +87838 -0
  6. data/app/assets/builds/avo.js.map +7 -0
  7. data/app/assets/svgs/x.svg +3 -0
  8. data/app/components/avo/fields/belongs_to_field/autocomplete_component.html.erb +22 -0
  9. data/app/components/avo/fields/belongs_to_field/autocomplete_component.rb +33 -0
  10. data/app/components/avo/fields/belongs_to_field/edit_component.html.erb +39 -33
  11. data/app/components/avo/fields/common/files_list_viewer_component.html.erb +1 -1
  12. data/app/components/avo/fields/common/multiple_file_viewer_component.html.erb +2 -0
  13. data/app/components/avo/fields/common/multiple_file_viewer_component.rb +2 -1
  14. data/app/components/avo/fields/common/single_file_viewer_component.html.erb +2 -0
  15. data/app/components/avo/fields/common/single_file_viewer_component.rb +2 -1
  16. data/app/components/avo/fields/file_field/edit_component.html.erb +1 -1
  17. data/app/components/avo/fields/file_field/index_component.html.erb +3 -1
  18. data/app/components/avo/fields/file_field/show_component.html.erb +1 -1
  19. data/app/components/avo/resource_component.rb +1 -0
  20. data/app/components/avo/views/resource_index_component.html.erb +1 -1
  21. data/app/controllers/avo/base_controller.rb +1 -2
  22. data/app/controllers/avo/relations_controller.rb +4 -4
  23. data/app/controllers/avo/search_controller.rb +0 -1
  24. data/app/javascript/js/controllers/fields/belongs_to_field_controller.js +96 -33
  25. data/app/javascript/js/controllers/search_controller.js +83 -17
  26. data/app/views/avo/partials/_global_search.html.erb +0 -1
  27. data/app/views/avo/partials/_javascript.html.erb +1 -0
  28. data/app/views/avo/partials/_resource_search.html.erb +0 -1
  29. data/db/factories.rb +4 -0
  30. data/lib/avo/fields/base_field.rb +1 -1
  31. data/lib/avo/fields/belongs_to_field.rb +19 -2
  32. data/lib/avo/fields/file_field.rb +2 -0
  33. data/lib/avo/fields/files_field.rb +2 -0
  34. data/lib/avo/fields/key_value_field.rb +4 -4
  35. data/lib/avo/licensing/pro_license.rb +2 -1
  36. data/lib/avo/version.rb +1 -1
  37. data/lib/generators/avo/templates/locales/avo.en.yml +1 -0
  38. data/public/avo-assets/avo.css +16 -0
  39. data/public/avo-assets/avo.js +317 -234
  40. data/public/avo-assets/avo.js.map +2 -2
  41. metadata +10 -4
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
2
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
3
+ </svg>
@@ -0,0 +1,22 @@
1
+ <div data-controller="search" class="resource-search flex items-center h-full w-full" data-turbo-remove-before-cache>
2
+ <div class="w-full hidden"
3
+ data-search-target="autocomplete"
4
+ data-search-resource="<%= @model_key %>"
5
+ data-translation-keys='{"no_item_found": "<%= I18n.translate 'avo.no_item_found' %>"}'
6
+ data-via-association="belongs_to"
7
+ ></div>
8
+ <div class="relative w-full" autocomplete="off">
9
+ <%= @form.text_field @field.foreign_key,
10
+ value: field_label,
11
+ class: helpers.input_classes('w-full', has_error: @field.model_errors.include?(@field.id)), 'data-search-target': 'button clearValue',
12
+ placeholder: @field.placeholder,
13
+ disabled: true %>
14
+ <div class="absolute top-1/2 left-auto right-3 mr-px -mt-2 cursor-pointer hidden text-gray-500"
15
+ data-tippy="tooltip"
16
+ data-search-target="clearButton"
17
+ title="<%= I18n.translate 'avo.clear_value' %>"
18
+ data-action="click->search#clearValue"
19
+ ><%= helpers.svg 'x', class: 'h-4' %></div>
20
+ </div>
21
+ <%= @form.hidden_field @foreign_key, value: field_value, 'data-search-target': 'hiddenId clearValue' %>
22
+ </div>
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Avo::Fields::BelongsToField::AutocompleteComponent < ViewComponent::Base
4
+ def initialize(form:, field:, type: nil, model_key:, foreign_key:)
5
+ @form = form
6
+ @field = field
7
+ @type = type
8
+ @model_key = model_key
9
+ @foreign_key = foreign_key
10
+ end
11
+
12
+ def field_label
13
+ if searchable?
14
+ @field.value&.class == @type ? @field.field_label : nil
15
+ else
16
+ @field.field_label
17
+ end
18
+ end
19
+
20
+ def field_value
21
+ if searchable?
22
+ @field.value&.class == @type ? @field.field_value : nil
23
+ else
24
+ @field.field_value
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def searchable?
31
+ @type.present?
32
+ end
33
+ end
@@ -1,22 +1,34 @@
1
- <% if @field.types.present? %>
2
- <div data-controller="belongs-to-field">
1
+ <%
2
+ if @field.types.present? # It's a polymorphic association
3
+
4
+ # Set the model keys so we can pass them over
5
+ model_keys = @field.types.map do |type|
6
+ resource = Avo::App.get_resource_by_model_name(type.to_s)
7
+ [type.to_s, resource.model_key]
8
+ end.to_h
9
+ %>
10
+ <div data-controller="belongs-to-field"
11
+ data-searchable="<%= @field.searchable %>"
12
+ data-association="<%= @field.id %>"
13
+ data-association-class="<%= @field&.target_resource&.model_class || nil %>"
14
+ >
3
15
  <%= edit_field_wrapper field: @field, index: @index, form: @form, resource: @resource, displayed_in_modal: @displayed_in_modal do %>
4
16
  <%= @form.select "#{@field.foreign_key}_type", @field.types.map { |type| [type.to_s.underscore.humanize, type.to_s] },
5
17
  {
18
+ value: @field.value,
6
19
  include_blank: @field.placeholder,
7
20
  },
8
21
  {
9
22
  class: helpers.input_classes('w-full', has_error: @field.model_errors.include?(@field.id)),
10
23
  disabled: disabled,
11
24
  'data-belongs-to-field-target': "select",
12
- 'data-action': 'change->belongs-to-field#changedType'
25
+ 'data-action': 'change->belongs-to-field#changeType'
13
26
  }
14
27
  %>
15
28
  <%
16
29
  # If the select field is disabled, no value will be sent. It's how HTML works.
17
30
  # Thus the extra hidden field to actually send the related id to the server.
18
- if disabled
19
- %>
31
+ if disabled %>
20
32
  <%= @form.hidden_field "#{@field.foreign_key}_type" %>
21
33
  <% end %>
22
34
  <% end %>
@@ -26,21 +38,18 @@
26
38
  data-type="<%= type %>"
27
39
  >
28
40
  <%= edit_field_wrapper field: @field, index: @index, form: @form, resource: @resource, displayed_in_modal: @displayed_in_modal, label: type.to_s.underscore.humanize do %>
29
- <%= @form.select "#{@field.foreign_key}_id", @field.values_for_type(type),
30
- {
31
- include_blank: @field.placeholder,
32
- },
33
- {
34
- class: helpers.input_classes('w-full', has_error: @field.model_errors.include?(@field.id)),
35
- disabled: disabled
36
- }
41
+ <% if @field.searchable %>
42
+ <%= render Avo::Fields::BelongsToField::AutocompleteComponent.new form: @form, field: @field, type: type, model_key: model_keys[type.to_s], foreign_key: "#{@field.foreign_key}_id" %>
43
+ <% else %>
44
+ <%= @form.select "#{@field.foreign_key}_id", options_for_select(@field.values_for_type(type), @field.value&.class == type ? @field.field_value : nil),
45
+ {
46
+ include_blank: @field.placeholder,
47
+ },
48
+ {
49
+ class: helpers.input_classes('w-full', has_error: @field.model_errors.include?(@field.id)),
50
+ disabled: disabled
51
+ }
37
52
  %>
38
- <%
39
- # If the select field is disabled, no value will be sent. It's how HTML works.
40
- # Thus the extra hidden field to actually send the related id to the server.
41
- if disabled
42
- %>
43
- <%= @form.hidden_field "#{@field.foreign_key}_id" %>
44
53
  <% end %>
45
54
  <% end %>
46
55
  </div>
@@ -48,21 +57,18 @@
48
57
  </div>
49
58
  <% else %>
50
59
  <%= edit_field_wrapper field: @field, index: @index, form: @form, resource: @resource, displayed_in_modal: @displayed_in_modal do %>
51
- <%= @form.select @field.foreign_key, @field.options.map { |o| [o[:label], o[:value]] },
52
- {
53
- include_blank: @field.placeholder,
54
- },
55
- {
56
- class: helpers.input_classes('w-full', has_error: @field.model_errors.include?(@field.id)),
57
- disabled: disabled
58
- }
60
+ <% if @field.searchable %>
61
+ <%= render Avo::Fields::BelongsToField::AutocompleteComponent.new form: @form, field: @field, model_key: @field.target_resource&.model_key, foreign_key: @field.foreign_key %>
62
+ <% else %>
63
+ <%= @form.select @field.foreign_key, @field.options.map { |o| [o[:label], o[:value]] },
64
+ {
65
+ include_blank: @field.placeholder,
66
+ },
67
+ {
68
+ class: helpers.input_classes('w-full', has_error: @field.model_errors.include?(@field.id)),
69
+ disabled: disabled
70
+ }
59
71
  %>
60
- <%
61
- # If the select field is disabled, no value will be sent. It's how HTML works.
62
- # Thus the extra hidden field to actually send the related id to the server.
63
- if disabled
64
- %>
65
- <%= @form.hidden_field @field.foreign_key %>
66
72
  <% end %>
67
73
  <% end %>
68
74
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <div class="relative p-3 bg-slate-200 grid grid-cols-3 xl:grid-cols-4 gap-3 rounded-xl">
2
2
  <% @field.value.attachments.each do |file| %>
3
- <%= render Avo::Fields::Common::MultipleFileViewerComponent.new id: @field.id, file: file, is_image: @field.is_image, button_size: :xs, resource: @resource %>
3
+ <%= render Avo::Fields::Common::MultipleFileViewerComponent.new id: @field.id, file: file, is_image: @field.is_image, is_audio: @field.is_audio, button_size: :xs, resource: @resource %>
4
4
  <% end %>
5
5
  </div>
@@ -2,6 +2,8 @@
2
2
  <% if @file.present? %>
3
3
  <% if @file.representable? && @is_image %>
4
4
  <%= image_tag helpers.main_app.url_for(@file), class: 'rounded-lg max-h-168 max-w-full' %>
5
+ <% elsif @is_audio %>
6
+ <%= audio_tag(helpers.main_app.url_for(@file), controls: true, preload: false, class: 'w-full')%>
5
7
  <% else %>
6
8
  <div class="relative flex flex-col justify-evenly items-center px-2 rounded-lg border bg-white border-gray-500 py-6 flex-1">
7
9
  <div class="flex flex-col justify-center items-center w-full">
@@ -3,10 +3,11 @@
3
3
  class Avo::Fields::Common::MultipleFileViewerComponent < ViewComponent::Base
4
4
  include Avo::ApplicationHelper
5
5
 
6
- def initialize(id:, file:, is_image:, direct_upload: false, resource:, button_size: :md)
6
+ def initialize(id:, file:, is_image:, is_audio:, direct_upload: false, resource:, button_size: :md)
7
7
  @id = id
8
8
  @file = file
9
9
  @is_image = is_image
10
+ @is_audio = is_audio
10
11
  @direct_upload = direct_upload
11
12
  @button_size = button_size
12
13
  @resource = resource
@@ -2,6 +2,8 @@
2
2
  <% if @file.present? %>
3
3
  <% if @file.representable? && @is_image %>
4
4
  <%= image_tag helpers.main_app.url_for(@file), class: 'rounded-lg max-h-168 max-w-full' %>
5
+ <% elsif @is_audio %>
6
+ <%= audio_tag(helpers.main_app.url_for(@file), controls: true, preload: false, class: 'w-full')%>
5
7
  <% else %>
6
8
  <div class="relative flex flex-col justify-evenly items-center px-2 rounded-lg border bg-white border-gray-500 min-h-48">
7
9
  <div class="flex flex-col justify-center items-center w-full">
@@ -3,10 +3,11 @@
3
3
  class Avo::Fields::Common::SingleFileViewerComponent < ViewComponent::Base
4
4
  include Avo::ApplicationHelper
5
5
 
6
- def initialize(id:, file:, is_image:, direct_upload: false, resource:, button_size: :md)
6
+ def initialize(id:, file:, is_image:, is_audio:, direct_upload: false, resource:, button_size: :md)
7
7
  @id = id
8
8
  @file = file
9
9
  @is_image = is_image
10
+ @is_audio = is_audio
10
11
  @direct_upload = direct_upload
11
12
  @button_size = button_size
12
13
  @resource = resource
@@ -1,7 +1,7 @@
1
1
  <%= edit_field_wrapper field: @field, index: @index, form: @form, resource: @resource, displayed_in_modal: @displayed_in_modal do %>
2
2
  <% if @field.value.present? %>
3
3
  <div class="mb-2">
4
- <%= render Avo::Fields::Common::SingleFileViewerComponent.new resource: @resource, id: @field.id, file: @field.value, is_image: @field.is_image, button_size: :md %>
4
+ <%= render Avo::Fields::Common::SingleFileViewerComponent.new resource: @resource, id: @field.id, file: @field.value, is_image: @field.is_image, is_audio: @field.is_audio, button_size: :md %>
5
5
  </div>
6
6
  <% end %>
7
7
 
@@ -1,7 +1,9 @@
1
1
  <%= index_field_wrapper field: @field do %>
2
2
  <% if @field.value.present? %>
3
3
  <% if @field.value.attached? && @field.value.representable? && @field.is_image %>
4
- <%= link_to_if @field.link_to_resource, image_tag(helpers.main_app.url_for(@field.value), class: 'max-h-full'), resource_path, class: 'block h-8' %>
4
+ <%= link_to_if @field.link_to_resource, image_tag(helpers.main_app.url_for(@field.value), class: 'max-h-full'), resource_path, class: 'block' %>
5
+ <% elsif @field.value.attached? && @field.is_audio %>
6
+ <%= link_to_if @field.link_to_resource, audio_tag(helpers.main_app.url_for(@field.value), controls: true, preload: false, class: 'max-h-full'), resource_path, class: 'block h-8' %>
5
7
  <% else %>
6
8
  <%= @field.value.filename %>
7
9
  <% end %>
@@ -1,3 +1,3 @@
1
1
  <%= show_field_wrapper field: @field do %>
2
- <%= render Avo::Fields::Common::SingleFileViewerComponent.new resource: @resource, id: @field.id, file: @field.value.attachment, is_image: @field.is_image, button_size: :md %>
2
+ <%= render Avo::Fields::Common::SingleFileViewerComponent.new resource: @resource, id: @field.id, file: @field.value.attachment, is_image: @field.is_image, is_audio: @field.is_audio, button_size: :md %>
3
3
  <% end %>
@@ -16,6 +16,7 @@ class Avo::ResourceComponent < ViewComponent::Base
16
16
 
17
17
  if @reflection.present?
18
18
  reflection_resource = ::Avo::App.get_resource_by_model_name(@reflection.active_record.name)
19
+ reflection_resource.hydrate(model: @parent_model) if @parent_model.present?
19
20
  association_name = params['related_name']
20
21
 
21
22
  if association_name.present?
@@ -24,7 +24,7 @@
24
24
  data-selected-resources="[]"
25
25
  >
26
26
  <div class="flex items-center px-6 w-64">
27
- <%= render partial: 'avo/partials/resource_search', locals: {resource: @resource.model_class.model_name.plural} if @resource.search_query.present? %>
27
+ <%= render partial: 'avo/partials/resource_search', locals: {resource: @resource.model_key} if @resource.search_query.present? %>
28
28
  </div>
29
29
  <div class="flex justify-end items-center px-6 space-x-3">
30
30
  <%= render partial: 'avo/partials/view_toggle_button', locals: { available_view_types: available_view_types, view_type: view_type, turbo_frame: @turbo_frame } if @models.present? %>
@@ -7,6 +7,7 @@ module Avo
7
7
  before_action :hydrate_resource
8
8
  before_action :set_model, only: [:show, :edit, :destroy, :update]
9
9
  before_action :set_model_to_fill
10
+ before_action :fill_model, only: [:create, :update]
10
11
  before_action :authorize_action
11
12
  before_action :reset_pagination_if_filters_changed, only: :index
12
13
  before_action :cache_applied_filters, only: :index
@@ -112,7 +113,6 @@ module Avo
112
113
 
113
114
  def create
114
115
  # model gets instantiated and filled in the fill_model method
115
- fill_model
116
116
  saved = @model.save
117
117
  @resource.hydrate(model: @model, view: :new, user: _current_user)
118
118
 
@@ -166,7 +166,6 @@ module Avo
166
166
 
167
167
  def update
168
168
  # model gets instantiated and filled in the fill_model method
169
- fill_model
170
169
  saved = @model.save
171
170
  @resource = @resource.hydrate(model: @model, view: :edit, user: _current_user)
172
171
 
@@ -4,11 +4,11 @@ module Avo
4
4
  class RelationsController < BaseController
5
5
  before_action :set_model, only: [:show, :index, :new, :create, :destroy]
6
6
  before_action :set_related_resource_name
7
- before_action :set_related_resource
8
- before_action :hydrate_related_resource
7
+ before_action :set_related_resource, only: [:show, :index, :new, :create, :destroy]
8
+ before_action :hydrate_related_resource, only: [:show, :index, :new, :create, :destroy]
9
9
  before_action :set_related_model, only: [:show]
10
- before_action :set_attachment_class
11
- before_action :set_attachment_resource
10
+ before_action :set_attachment_class, only: [:show, :index, :new, :create, :destroy]
11
+ before_action :set_attachment_resource, only: [:show, :index, :new, :create, :destroy]
12
12
  before_action :set_attachment_model, only: [:create, :destroy]
13
13
  before_action :set_reflection, only: [:index, :show]
14
14
 
@@ -60,7 +60,6 @@ module Avo
60
60
  _id: model.id,
61
61
  _label: resource.label,
62
62
  _url: resource.record_path,
63
- model: model
64
63
  }
65
64
 
66
65
  if App.license.has_with_trial(:enhanced_search_results)
@@ -1,65 +1,128 @@
1
1
  import { Controller } from 'stimulus'
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ['select', 'type']
4
+ static targets = ['select', 'type', 'loadAssociationLink'];
5
+
6
+ defaults = {};
5
7
 
6
8
  get selectedType() {
7
9
  return this.selectTarget.value
8
10
  }
9
11
 
10
- connect() {
11
- this.setValidNames()
12
- this.changedType()
12
+ get isSearchable() {
13
+ return this.context.scope.element.dataset.searchable === 'true'
13
14
  }
14
15
 
15
- setValidNames() {
16
- this.typeTargets.forEach((target) => {
17
- const { type } = target.dataset
18
- const select = target.querySelector('select')
19
- const name = select.getAttribute('name')
16
+ get association() {
17
+ return this.context.scope.element.dataset.association
18
+ }
20
19
 
21
- select.setAttribute('valid-name', name)
22
- if (this.selectedType !== type) {
23
- select.selectedIndex = 0
24
- }
25
- })
20
+ get associationClass() {
21
+ return this.context.scope.element.dataset.associationClass
26
22
  }
27
23
 
28
- changedType() {
29
- this.hideAllTypeTargets()
30
- this.enableType(this.selectTarget.value)
24
+ connect() {
25
+ this.copyValidNames()
26
+ this.changeType() // Do the initial type change
31
27
  }
32
28
 
33
- hideAllTypeTargets() {
29
+ changeType() {
30
+ this.hideAllTypes()
31
+ this.showType(this.selectTarget.value)
32
+ }
33
+
34
+ // Private
35
+
36
+ hideAllTypes() {
34
37
  this.typeTargets.forEach((target) => {
35
- this.hideTarget(target)
38
+ target.classList.add('hidden')
39
+
36
40
  this.invalidateTarget(target)
37
41
  })
38
42
  }
39
43
 
40
- hideTarget(target) {
41
- target.classList.add('hidden')
42
- }
44
+ /**
45
+ * Used for invalidating select fields when switching between types so they don't automatically override the previous id.
46
+ * Ex: There are two types Article and Project and the Comment has commentable_id 3 and commentable_type: Article
47
+ * When you change the type from Project to Article the Project field will override the commentable_id value
48
+ * because it was rendered later (alphabetical sorting) and the browser will pick that one up.
49
+ * So we go and copy the name attribute to valid-name for all types and then copy it back to name when the user selects it.
50
+ */
43
51
 
44
- invalidateTarget(target) {
45
- const select = target.querySelector('select')
52
+ /**
53
+ * This method does the initial copying from name to valid-name.
54
+ */
55
+ copyValidNames() {
56
+ this.typeTargets.forEach((target) => {
57
+ const { type } = target.dataset
46
58
 
47
- select.setAttribute('name', '')
48
- }
59
+ if (this.isSearchable) {
60
+ const textInput = target.querySelector('input[type="text"]')
61
+ if (textInput) {
62
+ textInput.setAttribute('valid-name', textInput.getAttribute('name'))
63
+ }
49
64
 
50
- validateTarget(target) {
51
- const select = target.querySelector('select')
52
- const validName = select.getAttribute('valid-name')
65
+ const hiddenInput = target.querySelector('input[type="hidden"]')
66
+ if (hiddenInput) {
67
+ hiddenInput.setAttribute(
68
+ 'valid-name',
69
+ hiddenInput.getAttribute('name'),
70
+ )
71
+ }
72
+ } else {
73
+ const select = target.querySelector('select')
74
+ if (select) {
75
+ select.setAttribute('valid-name', select.getAttribute('name'))
76
+ }
53
77
 
54
- select.setAttribute('name', validName)
78
+ if (this.selectedType !== type) {
79
+ select.selectedIndex = 0
80
+ }
81
+ }
82
+ })
55
83
  }
56
84
 
57
- enableType(type) {
58
- const target = this.typeTargets.find((typeTarget) => typeTarget.dataset.type === type)
59
-
85
+ showType(type) {
86
+ const target = this.typeTargets.find(
87
+ (typeTarget) => typeTarget.dataset.type === type,
88
+ )
60
89
  if (target) {
61
90
  target.classList.remove('hidden')
91
+
62
92
  this.validateTarget(target)
63
93
  }
64
94
  }
95
+
96
+ /**
97
+ * Copy value from `valid-name` to `name`
98
+ */
99
+ validateTarget(target) {
100
+ if (this.isSearchable) {
101
+ const textInput = target.querySelector('input[type="text"]')
102
+ const hiddenInput = target.querySelector('input[type="hidden"]')
103
+
104
+ textInput.setAttribute('name', textInput.getAttribute('valid-name'))
105
+ hiddenInput.setAttribute('name', hiddenInput.getAttribute('valid-name'))
106
+ } else {
107
+ const select = target.querySelector('select')
108
+ select.setAttribute('name', select.getAttribute('valid-name'))
109
+ }
110
+ }
111
+
112
+ /**
113
+ * nullify the `name` attribute
114
+ */
115
+ invalidateTarget(target) {
116
+ if (this.isSearchable) {
117
+ // Wrapping it in a try/catch to counter turbo's cache system (going back to the edit page after initial save)
118
+ try {
119
+ target.querySelector('input[type="text"]').setAttribute('name', '')
120
+ target.querySelector('input[type="hidden"]').setAttribute('name', '')
121
+ } catch {}
122
+ } else if (target) {
123
+ try {
124
+ target.querySelector('select').setAttribute('name', '')
125
+ } catch (error) {}
126
+ }
127
+ }
65
128
  }
@@ -3,17 +3,40 @@ import * as Mousetrap from 'mousetrap'
3
3
  import { Controller } from 'stimulus'
4
4
  import { Turbo } from '@hotwired/turbo-rails'
5
5
  import { autocomplete } from '@algolia/autocomplete-js'
6
+ import URI from 'urijs'
6
7
  import debouncePromise from '../helpers/debounce_promise'
7
8
 
9
+ /**
10
+ * The search controller is used in three places.
11
+ * 1. Global search (on the top navbar) and can search through multiple resources.
12
+ * 2. Resource search (on the Index page on top of the table panel) and will search one resource
13
+ * 3. belongs_to field. This requires a bit more cleanup because the user will not navigate away from the page.
14
+ * It will replace the id and label in some fields on the page and also needs a "clear" button which clears the information so the user can submit the form without a value.
15
+ */
8
16
  export default class extends Controller {
9
- static targets = ['autocomplete', 'button']
17
+ static targets = [
18
+ 'autocomplete',
19
+ 'button',
20
+ 'hiddenId',
21
+ 'visibleLabel',
22
+ 'clearValue',
23
+ 'clearButton',
24
+ ];
25
+
26
+ debouncedFetch = debouncePromise(fetch, this.searchDebounce);
27
+
28
+ get dataset() {
29
+ return this.autocompleteTarget.dataset
30
+ }
10
31
 
11
- debouncedFetch = debouncePromise(fetch, this.debounceTimeout)
32
+ get searchDebounce() {
33
+ return window.Avo.configuration.search_debounce
34
+ }
12
35
 
13
36
  get translationKeys() {
14
37
  let keys
15
38
  try {
16
- keys = JSON.parse(this.autocompleteTarget.dataset.translationKeys)
39
+ keys = JSON.parse(this.dataset.translationKeys)
17
40
  } catch (error) {
18
41
  keys = {}
19
42
  }
@@ -21,20 +44,48 @@ export default class extends Controller {
21
44
  return keys
22
45
  }
23
46
 
24
- get debounceTimeout() {
25
- return this.autocompleteTarget.dataset.debounceTimeout
47
+ get isBelongsToSearch() {
48
+ return this.dataset.viaAssociation === 'belongs_to'
26
49
  }
27
50
 
28
- get searchResource() {
29
- return this.autocompleteTarget.dataset.searchResource
51
+ get isGlobalSearch() {
52
+ return this.dataset.searchResource === 'global'
30
53
  }
31
54
 
32
- get isGlobalSearch() {
33
- return this.searchResource === 'global'
55
+ searchUrl(query) {
56
+ const url = URI()
57
+
58
+ let params = { q: query }
59
+ let segments = [
60
+ window.Avo.configuration.root_path,
61
+ 'avo_api',
62
+ this.dataset.searchResource,
63
+ 'search',
64
+ ]
65
+
66
+ if (this.isGlobalSearch) {
67
+ segments = [window.Avo.configuration.root_path, 'avo_api', 'search']
68
+ }
69
+
70
+ if (this.isBelongsToSearch) {
71
+ // eslint-disable-next-line camelcase
72
+ params = { ...params, via_association: this.dataset.viaAssociation }
73
+ }
74
+
75
+ return url.segment(segments).search(params).toString()
34
76
  }
35
77
 
36
- get searchUrl() {
37
- return this.isGlobalSearch ? `${window.Avo.configuration.root_path}/avo_api/search` : `${window.Avo.configuration.root_path}/avo_api/${this.searchResource}/search`
78
+ handleOnSelect({ item }) {
79
+ if (this.isBelongsToSearch) {
80
+ this.hiddenIdTarget.setAttribute('value', item._id)
81
+ this.buttonTarget.setAttribute('value', item._label)
82
+
83
+ document.querySelector('.aa-DetachedOverlay').remove()
84
+
85
+ this.clearButtonTarget.classList.remove('hidden')
86
+ } else {
87
+ Turbo.visit(item._url, { action: 'advance' })
88
+ }
38
89
  }
39
90
 
40
91
  addSource(resourceName, data) {
@@ -43,9 +94,7 @@ export default class extends Controller {
43
94
  return {
44
95
  sourceId: resourceName,
45
96
  getItems: () => data.results,
46
- onSelect({ item }) {
47
- Turbo.visit(item._url, { action: 'replace' })
48
- },
97
+ onSelect: that.handleOnSelect.bind(that),
49
98
  templates: {
50
99
  header() {
51
100
  return `${data.header.toUpperCase()} ${data.help}`
@@ -84,7 +133,10 @@ export default class extends Controller {
84
133
  })
85
134
  },
86
135
  noResults() {
87
- return that.translationKeys.no_item_found.replace('%{item}', resourceName)
136
+ return that.translationKeys.no_item_found.replace(
137
+ '%{item}',
138
+ resourceName,
139
+ )
88
140
  },
89
141
  },
90
142
  }
@@ -94,11 +146,22 @@ export default class extends Controller {
94
146
  this.autocompleteTarget.querySelector('button').click()
95
147
  }
96
148
 
149
+ clearValue() {
150
+ this.clearValueTargets.map((e) => e.setAttribute('value', ''))
151
+ this.clearButtonTarget.classList.add('hidden')
152
+ }
153
+
97
154
  connect() {
98
155
  const that = this
99
156
 
100
157
  this.buttonTarget.onclick = () => this.showSearchPanel()
101
158
 
159
+ this.clearValueTargets.forEach((target) => {
160
+ if (target.getAttribute('value')) {
161
+ this.clearButtonTarget.classList.remove('hidden')
162
+ }
163
+ })
164
+
102
165
  if (this.isGlobalSearch) {
103
166
  Mousetrap.bind(['command+k', 'ctrl+k'], () => this.showSearchPanel())
104
167
  }
@@ -112,12 +175,15 @@ export default class extends Controller {
112
175
  openOnFocus: true,
113
176
  detachedMediaQuery: '',
114
177
  getSources: ({ query }) => {
115
- const endpoint = `${that.searchUrl}?q=${query}`
178
+ const endpoint = that.searchUrl(query)
116
179
 
117
- return that.debouncedFetch(endpoint)
180
+ return that
181
+ .debouncedFetch(endpoint)
118
182
  .then((response) => response.json())
119
183
  .then((data) => Object.keys(data).map((resourceName) => that.addSource(resourceName, data[resourceName])))
120
184
  },
121
185
  })
186
+
187
+ this.buttonTarget.removeAttribute('disabled')
122
188
  }
123
189
  }
@@ -3,7 +3,6 @@
3
3
  data-search-target="autocomplete"
4
4
  data-search-resource="global"
5
5
  data-translation-keys='{"no_item_found": "<%= I18n.translate 'avo.no_item_found' %>", "placeholder": "<%= I18n.translate 'avo.search.placeholder' %>", "cancel_button": "<%= I18n.translate 'avo.search.cancel_button' %>"}'
6
- data-debounce-timeout='<%= Avo.configuration.search_debounce %>'
7
6
  >
8
7
  </div>
9
8
  <div class="relative top-[-5px] inline-flex text-gray-400 text-sm leading-5 py-0.5 px-1.5 border border-gray-300 rounded-md cursor-pointer"