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.
- checksums.yaml +4 -4
- data/Gemfile +3 -1
- data/Gemfile.lock +7 -3
- data/app/assets/builds/avo.css +8606 -0
- data/app/assets/builds/avo.js +87838 -0
- data/app/assets/builds/avo.js.map +7 -0
- data/app/assets/svgs/x.svg +3 -0
- data/app/components/avo/fields/belongs_to_field/autocomplete_component.html.erb +22 -0
- data/app/components/avo/fields/belongs_to_field/autocomplete_component.rb +33 -0
- data/app/components/avo/fields/belongs_to_field/edit_component.html.erb +39 -33
- data/app/components/avo/fields/common/files_list_viewer_component.html.erb +1 -1
- data/app/components/avo/fields/common/multiple_file_viewer_component.html.erb +2 -0
- data/app/components/avo/fields/common/multiple_file_viewer_component.rb +2 -1
- data/app/components/avo/fields/common/single_file_viewer_component.html.erb +2 -0
- data/app/components/avo/fields/common/single_file_viewer_component.rb +2 -1
- data/app/components/avo/fields/file_field/edit_component.html.erb +1 -1
- data/app/components/avo/fields/file_field/index_component.html.erb +3 -1
- data/app/components/avo/fields/file_field/show_component.html.erb +1 -1
- data/app/components/avo/resource_component.rb +1 -0
- data/app/components/avo/views/resource_index_component.html.erb +1 -1
- data/app/controllers/avo/base_controller.rb +1 -2
- data/app/controllers/avo/relations_controller.rb +4 -4
- data/app/controllers/avo/search_controller.rb +0 -1
- data/app/javascript/js/controllers/fields/belongs_to_field_controller.js +96 -33
- data/app/javascript/js/controllers/search_controller.js +83 -17
- data/app/views/avo/partials/_global_search.html.erb +0 -1
- data/app/views/avo/partials/_javascript.html.erb +1 -0
- data/app/views/avo/partials/_resource_search.html.erb +0 -1
- data/db/factories.rb +4 -0
- data/lib/avo/fields/base_field.rb +1 -1
- data/lib/avo/fields/belongs_to_field.rb +19 -2
- data/lib/avo/fields/file_field.rb +2 -0
- data/lib/avo/fields/files_field.rb +2 -0
- data/lib/avo/fields/key_value_field.rb +4 -4
- data/lib/avo/licensing/pro_license.rb +2 -1
- data/lib/avo/version.rb +1 -1
- data/lib/generators/avo/templates/locales/avo.en.yml +1 -0
- data/public/avo-assets/avo.css +16 -0
- data/public/avo-assets/avo.js +317 -234
- data/public/avo-assets/avo.js.map +2 -2
- metadata +10 -4
@@ -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
|
-
<%
|
2
|
-
|
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#
|
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
|
-
|
30
|
-
{
|
31
|
-
|
32
|
-
},
|
33
|
-
|
34
|
-
|
35
|
-
|
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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
},
|
55
|
-
|
56
|
-
|
57
|
-
|
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
|
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.
|
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
|
|
@@ -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
|
-
|
11
|
-
this.
|
12
|
-
this.changedType()
|
12
|
+
get isSearchable() {
|
13
|
+
return this.context.scope.element.dataset.searchable === 'true'
|
13
14
|
}
|
14
15
|
|
15
|
-
|
16
|
-
this.
|
17
|
-
|
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
|
-
|
22
|
-
|
23
|
-
select.selectedIndex = 0
|
24
|
-
}
|
25
|
-
})
|
20
|
+
get associationClass() {
|
21
|
+
return this.context.scope.element.dataset.associationClass
|
26
22
|
}
|
27
23
|
|
28
|
-
|
29
|
-
this.
|
30
|
-
this.
|
24
|
+
connect() {
|
25
|
+
this.copyValidNames()
|
26
|
+
this.changeType() // Do the initial type change
|
31
27
|
}
|
32
28
|
|
33
|
-
|
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
|
-
|
38
|
+
target.classList.add('hidden')
|
39
|
+
|
36
40
|
this.invalidateTarget(target)
|
37
41
|
})
|
38
42
|
}
|
39
43
|
|
40
|
-
|
41
|
-
|
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
|
-
|
45
|
-
|
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
|
-
|
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
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
78
|
+
if (this.selectedType !== type) {
|
79
|
+
select.selectedIndex = 0
|
80
|
+
}
|
81
|
+
}
|
82
|
+
})
|
55
83
|
}
|
56
84
|
|
57
|
-
|
58
|
-
const target = this.typeTargets.find(
|
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 = [
|
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
|
-
|
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.
|
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
|
25
|
-
return this.
|
47
|
+
get isBelongsToSearch() {
|
48
|
+
return this.dataset.viaAssociation === 'belongs_to'
|
26
49
|
}
|
27
50
|
|
28
|
-
get
|
29
|
-
return this.
|
51
|
+
get isGlobalSearch() {
|
52
|
+
return this.dataset.searchResource === 'global'
|
30
53
|
}
|
31
54
|
|
32
|
-
|
33
|
-
|
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
|
-
|
37
|
-
|
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(
|
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(
|
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 =
|
178
|
+
const endpoint = that.searchUrl(query)
|
116
179
|
|
117
|
-
return that
|
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"
|