decidim-comments 0.24.3 → 0.25.0.rc4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -20
  3. data/app/assets/javascripts/decidim/comments/bundle.js.map +1 -1
  4. data/app/cells/decidim/comments/comment/actions.erb +1 -1
  5. data/app/cells/decidim/comments/comment/deletion_data.erb +1 -0
  6. data/app/cells/decidim/comments/comment/show.erb +30 -21
  7. data/app/cells/decidim/comments/comment/utilities.erb +40 -12
  8. data/app/cells/decidim/comments/comment/votes.erb +6 -6
  9. data/app/cells/decidim/comments/comment_cell.rb +29 -0
  10. data/app/cells/decidim/comments/comment_form/show.erb +1 -1
  11. data/app/cells/decidim/comments/comments/add_comment.erb +10 -6
  12. data/app/cells/decidim/comments/comments/order_control.erb +4 -5
  13. data/app/cells/decidim/comments/comments/show.erb +2 -4
  14. data/app/cells/decidim/comments/comments/user_comments_blocked_warning.erb +5 -1
  15. data/app/cells/decidim/comments/comments_cell.rb +24 -2
  16. data/app/cells/decidim/comments/edit_comment_modal_form/show.erb +29 -0
  17. data/app/cells/decidim/comments/edit_comment_modal_form_cell.rb +53 -0
  18. data/app/commands/decidim/comments/create_comment.rb +2 -1
  19. data/app/commands/decidim/comments/delete_comment.rb +46 -0
  20. data/app/commands/decidim/comments/update_comment.rb +62 -0
  21. data/app/controllers/decidim/comments/comments_controller.rb +63 -6
  22. data/app/events/decidim/comments/comment_voted_event.rb +9 -0
  23. data/app/models/decidim/comments/comment.rb +24 -1
  24. data/app/packs/src/decidim/comments/comments.component.js +300 -0
  25. data/app/{assets/javascripts → packs/src}/decidim/comments/comments.component.test.js +116 -26
  26. data/app/packs/src/decidim/comments/comments.component_for_testing.js +8 -0
  27. data/app/packs/src/decidim/comments/comments.js +1 -0
  28. data/app/permissions/decidim/comments/permissions.rb +10 -1
  29. data/app/queries/decidim/comments/metrics/comment_participants_metric_measure.rb +1 -1
  30. data/app/queries/decidim/comments/metrics/comments_metric_manage.rb +1 -1
  31. data/app/queries/decidim/comments/sorted_comments.rb +8 -6
  32. data/app/views/decidim/comments/comments/_delete.html.erb +5 -0
  33. data/app/views/decidim/comments/comments/_edited_comment.html.erb +1 -0
  34. data/app/views/decidim/comments/comments/create.js.erb +4 -2
  35. data/app/views/decidim/comments/comments/delete.js.erb +17 -0
  36. data/app/views/decidim/comments/comments/deletion_error.js.erb +1 -0
  37. data/app/views/decidim/comments/comments/reload.js.erb +2 -0
  38. data/app/views/decidim/comments/comments/update.js.erb +8 -0
  39. data/app/views/decidim/comments/comments/update_error.js.erb +1 -0
  40. data/config/assets.rb +5 -0
  41. data/config/locales/ar.yml +0 -1
  42. data/config/locales/ca.yml +7 -1
  43. data/config/locales/cs.yml +25 -1
  44. data/config/locales/de.yml +7 -1
  45. data/config/locales/el.yml +0 -1
  46. data/config/locales/en.yml +25 -1
  47. data/config/locales/es-MX.yml +7 -1
  48. data/config/locales/es-PY.yml +7 -1
  49. data/config/locales/es.yml +7 -1
  50. data/config/locales/fi-plain.yml +25 -1
  51. data/config/locales/fi.yml +25 -1
  52. data/config/locales/fr-CA.yml +25 -1
  53. data/config/locales/fr-LU.yml +162 -0
  54. data/config/locales/fr.yml +25 -1
  55. data/config/locales/gl.yml +25 -1
  56. data/config/locales/hu.yml +0 -1
  57. data/config/locales/it.yml +38 -1
  58. data/config/locales/ja.yml +35 -1
  59. data/config/locales/lb-LU.yml +1 -0
  60. data/config/locales/lv.yml +0 -1
  61. data/config/locales/nl.yml +27 -2
  62. data/config/locales/no.yml +0 -1
  63. data/config/locales/pl.yml +7 -1
  64. data/config/locales/pt-BR.yml +61 -0
  65. data/config/locales/pt.yml +0 -1
  66. data/config/locales/ro-RO.yml +25 -1
  67. data/config/locales/sk.yml +0 -1
  68. data/config/locales/sr-CS.yml +0 -1
  69. data/config/locales/sv.yml +25 -1
  70. data/config/locales/tr-TR.yml +0 -1
  71. data/config/locales/zh-CN.yml +0 -1
  72. data/db/migrate/20200706123136_make_comments_handle_i18n.rb +1 -1
  73. data/db/migrate/20210402124534_add_participatory_process_to_comments.rb +12 -0
  74. data/db/migrate/20210529095942_add_deleted_at_column_to_comments.rb +7 -0
  75. data/lib/decidim/comments/commentable.rb +6 -1
  76. data/lib/decidim/comments/commentable_with_component.rb +33 -0
  77. data/lib/decidim/comments/engine.rb +1 -9
  78. data/lib/decidim/comments/test/factories.rb +1 -0
  79. data/lib/decidim/comments/version.rb +1 -1
  80. data/lib/decidim/comments.rb +1 -0
  81. data/lib/tasks/decidim_comments.rake +15 -0
  82. metadata +32 -29
  83. data/app/assets/config/decidim_comments_manifest.js +0 -1
  84. data/app/assets/javascripts/decidim/comments/comments.component.js.es6 +0 -292
  85. data/app/assets/javascripts/decidim/comments/comments.js.erb +0 -10
  86. data/config/locales/ja-JP.yml +0 -120
@@ -8,8 +8,8 @@ module Decidim
8
8
  include Decidim::ResourceHelper
9
9
 
10
10
  before_action :authenticate_user!, only: [:create]
11
- before_action :set_commentable
12
- before_action :ensure_commentable!
11
+ before_action :set_commentable, except: [:destroy, :update]
12
+ before_action :ensure_commentable!, except: [:destroy, :update]
13
13
 
14
14
  helper_method :root_depth, :commentable, :order, :reply?, :reload?
15
15
 
@@ -21,7 +21,7 @@ module Decidim
21
21
  order_by: order,
22
22
  after: params.fetch(:after, 0).to_i
23
23
  )
24
- @comments_count = commentable.comments.count
24
+ @comments_count = commentable.comments_count
25
25
 
26
26
  respond_to do |format|
27
27
  format.js do
@@ -37,6 +37,31 @@ module Decidim
37
37
  end
38
38
  end
39
39
 
40
+ def update
41
+ set_comment
42
+ enforce_permission_to :update, :comment, comment: comment
43
+
44
+ form = Decidim::Comments::CommentForm.from_params(
45
+ params.merge(commentable: comment.commentable)
46
+ ).with_context(
47
+ current_organization: current_organization
48
+ )
49
+
50
+ Decidim::Comments::UpdateComment.call(comment, current_user, form) do
51
+ on(:ok) do
52
+ respond_to do |format|
53
+ format.js { render :update }
54
+ end
55
+ end
56
+
57
+ on(:invalid) do
58
+ respond_to do |format|
59
+ format.js { render :update_error }
60
+ end
61
+ end
62
+ end
63
+ end
64
+
40
65
  def create
41
66
  enforce_permission_to :create, :comment, commentable: commentable
42
67
 
@@ -44,7 +69,7 @@ module Decidim
44
69
  params.merge(commentable: commentable)
45
70
  ).with_context(
46
71
  current_organization: current_organization,
47
- current_component: commentable.try(:component) || commentable.participatory_space
72
+ current_component: current_component
48
73
  )
49
74
  Decidim::Comments::CreateComment.call(form, current_user) do
50
75
  on(:ok) do |comment|
@@ -63,6 +88,34 @@ module Decidim
63
88
  end
64
89
  end
65
90
 
91
+ def current_component
92
+ return commentable.component if commentable.respond_to?(:component)
93
+ return commentable.participatory_space if commentable.respond_to?(:participatory_space)
94
+ return commentable if Decidim.participatory_space_manifests.find { |manifest| manifest.model_class_name == commentable.class.name }
95
+ end
96
+
97
+ def destroy
98
+ set_comment
99
+ @commentable = @comment.commentable
100
+
101
+ enforce_permission_to :destroy, :comment, comment: comment
102
+
103
+ Decidim::Comments::DeleteComment.call(comment, current_user) do
104
+ on(:ok) do
105
+ @comments_count = @comment.root_commentable.comments_count
106
+ respond_to do |format|
107
+ format.js { render :delete }
108
+ end
109
+ end
110
+
111
+ on(:invalid) do
112
+ respond_to do |format|
113
+ format.js { render :deletion_error }
114
+ end
115
+ end
116
+ end
117
+ end
118
+
66
119
  private
67
120
 
68
121
  attr_reader :commentable, :comment
@@ -71,6 +124,10 @@ module Decidim
71
124
  @commentable = GlobalID::Locator.locate_signed(commentable_gid)
72
125
  end
73
126
 
127
+ def set_comment
128
+ @comment = Decidim::Comments::Comment.find_by(id: params[:id])
129
+ end
130
+
74
131
  def ensure_commentable!
75
132
  raise ActionController::RoutingError, "Not Found" unless commentable
76
133
  end
@@ -80,9 +137,9 @@ module Decidim
80
137
  @comments_count = begin
81
138
  case commentable
82
139
  when Decidim::Comments::Comment
83
- commentable.root_commentable.comments.count
140
+ commentable.root_commentable.comments_count
84
141
  else
85
- commentable.comments.count
142
+ commentable.comments_count
86
143
  end
87
144
  end
88
145
  end
@@ -8,6 +8,11 @@ module Decidim
8
8
  i18n_attributes :upvotes
9
9
  i18n_attributes :downvotes
10
10
 
11
+ def initialize(resource:, event_name:, user:, user_role: nil, extra: nil)
12
+ resource = target_resource(resource)
13
+ super
14
+ end
15
+
11
16
  def upvotes
12
17
  extra[:upvotes]
13
18
  end
@@ -21,6 +26,10 @@ module Decidim
21
26
  def resource_url_params
22
27
  { anchor: "comment_#{comment.id}" }
23
28
  end
29
+
30
+ def target_resource(t_resource)
31
+ t_resource.is_a?(Decidim::Comments::Comment) ? t_resource.root_commentable : t_resource
32
+ end
24
33
  end
25
34
  end
26
35
  end
@@ -29,6 +29,7 @@ module Decidim
29
29
 
30
30
  belongs_to :commentable, foreign_key: "decidim_commentable_id", foreign_type: "decidim_commentable_type", polymorphic: true
31
31
  belongs_to :root_commentable, foreign_key: "decidim_root_commentable_id", foreign_type: "decidim_root_commentable_type", polymorphic: true, touch: true
32
+ belongs_to :participatory_space, foreign_key: "decidim_participatory_space_id", foreign_type: "decidim_participatory_space_type", polymorphic: true, optional: true
32
33
  has_many :up_votes, -> { where(weight: 1) }, foreign_key: "decidim_comment_id", class_name: "CommentVote", dependent: :destroy
33
34
  has_many :down_votes, -> { where(weight: -1) }, foreign_key: "decidim_comment_id", class_name: "CommentVote", dependent: :destroy
34
35
 
@@ -54,6 +55,8 @@ module Decidim
54
55
 
55
56
  delegate :organization, to: :commentable
56
57
 
58
+ scope :not_deleted, -> { where(deleted_at: nil) }
59
+
57
60
  translatable_fields :body
58
61
  searchable_fields({
59
62
  participatory_space: :itself,
@@ -79,7 +82,9 @@ module Decidim
79
82
  participatory_space.try(:visible?) && component.try(:published?)
80
83
  end
81
84
 
85
+ alias original_participatory_space participatory_space
82
86
  def participatory_space
87
+ return original_participatory_space if original_participatory_space.present?
83
88
  return root_commentable if root_commentable.is_a?(Decidim::Participable)
84
89
 
85
90
  root_commentable.participatory_space
@@ -91,6 +96,8 @@ module Decidim
91
96
 
92
97
  # Public: Override Commentable concern method `accepts_new_comments?`
93
98
  def accepts_new_comments?
99
+ return if deleted?
100
+
94
101
  root_commentable.accepts_new_comments? && depth < MAX_DEPTH
95
102
  end
96
103
 
@@ -156,7 +163,7 @@ module Decidim
156
163
  def self.user_commentators_ids_in(resources)
157
164
  if resources.first&.kind_of?(Decidim::Comments::Commentable)
158
165
  commentable_type = resources.first.class.name
159
- Decidim::Comments::Comment.select("DISTINCT decidim_author_id").not_hidden
166
+ Decidim::Comments::Comment.select("DISTINCT decidim_author_id").not_hidden.not_deleted
160
167
  .where(decidim_commentable_id: resources.pluck(:id))
161
168
  .where(decidim_commentable_type: commentable_type)
162
169
  .where("decidim_author_type" => "Decidim::UserBaseEntity").pluck(:decidim_author_id)
@@ -179,6 +186,22 @@ module Decidim
179
186
  @translated_body ||= translated_attribute(body, organization)
180
187
  end
181
188
 
189
+ def delete!
190
+ return if deleted?
191
+
192
+ update(deleted_at: Time.current)
193
+
194
+ update_counter
195
+ end
196
+
197
+ def deleted?
198
+ deleted_at.present?
199
+ end
200
+
201
+ def edited?
202
+ Decidim::ActionLog.where(resource: self).exists?(["extra @> ?", Arel.sql("{\"edit\":true}")])
203
+ end
204
+
182
205
  private
183
206
 
184
207
  def body_length
@@ -0,0 +1,300 @@
1
+ /* eslint id-length: ["error", { "exceptions": ["$"] }] */
2
+
3
+ /**
4
+ * A plain Javascript component that handles the comments.
5
+ *
6
+ * @class
7
+ * @augments Component
8
+ */
9
+
10
+ // This is necessary for testing purposes
11
+ const $ = window.$;
12
+
13
+ import { createCharacterCounter } from "src/decidim/input_character_counter"
14
+ import ExternalLink from "src/decidim/external_link"
15
+ import updateExternalDomainLinks from "src/decidim/external_domain_warning"
16
+
17
+ export default class CommentsComponent {
18
+ constructor($element, config) {
19
+ this.$element = $element;
20
+ this.commentableGid = config.commentableGid;
21
+ this.commentsUrl = config.commentsUrl;
22
+ this.rootDepth = config.rootDepth;
23
+ this.order = config.order;
24
+ this.lastCommentId = config.lastCommentId;
25
+ this.pollingInterval = config.pollingInterval || 15000;
26
+ this.id = this.$element.attr("id") || this._getUID();
27
+ this.mounted = false;
28
+ }
29
+
30
+ /**
31
+ * Handles the logic for mounting the component
32
+ * @public
33
+ * @returns {Void} - Returns nothing
34
+ */
35
+ mountComponent() {
36
+ if (this.$element.length > 0 && !this.mounted) {
37
+ this.mounted = true;
38
+ this._initializeComments(this.$element);
39
+
40
+ $(".order-by__dropdown .is-submenu-item a", this.$element).on("click.decidim-comments", () => this._onInitOrder());
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Handles the logic for unmounting the component
46
+ * @public
47
+ * @returns {Void} - Returns nothing
48
+ */
49
+ unmountComponent() {
50
+ if (this.mounted) {
51
+ this.mounted = false;
52
+ this._stopPolling();
53
+
54
+ $(".add-comment .opinion-toggle .button", this.$element).off("click.decidim-comments");
55
+ $(".add-comment textarea", this.$element).off("input.decidim-comments");
56
+ $(".order-by__dropdown .is-submenu-item a", this.$element).off("click.decidim-comments");
57
+ $(".add-comment form", this.$element).off("submit.decidim-comments");
58
+ $(".add-comment textarea", this.$element).each((_i, el) => el.removeEventListener("emoji.added", this._onTextInput));
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Adds a new thread to the comments section.
64
+ * @public
65
+ * @param {String} threadHtml - The HTML content for the thread.
66
+ * @param {Boolean} fromCurrentUser - A boolean indicating whether the user
67
+ * herself was the author of the new thread. Defaults to false.
68
+ * @returns {Void} - Returns nothing
69
+ */
70
+ addThread(threadHtml, fromCurrentUser = false) {
71
+ const $parent = $(".comments:first", this.$element);
72
+ const $comment = $(threadHtml);
73
+ const $threads = $(".comment-threads", this.$element);
74
+ this._addComment($threads, $comment);
75
+ this._finalizeCommentCreation($parent, fromCurrentUser);
76
+ }
77
+
78
+ /**
79
+ * Adds a new reply to an existing comment.
80
+ * @public
81
+ * @param {Number} commentId - The ID of the comment for which to add the
82
+ * reply to.
83
+ * @param {String} replyHtml - The HTML content for the reply.
84
+ * @param {Boolean} fromCurrentUser - A boolean indicating whether the user
85
+ * herself was the author of the new reply. Defaults to false.
86
+ * @returns {Void} - Returns nothing
87
+ */
88
+ addReply(commentId, replyHtml, fromCurrentUser = false) {
89
+ const $parent = $(`#comment_${commentId}`);
90
+ const $comment = $(replyHtml);
91
+ const $replies = $(`#comment-${commentId}-replies`);
92
+ this._addComment($replies, $comment);
93
+ $replies.siblings(".comment__additionalreply").removeClass("hide");
94
+ this._finalizeCommentCreation($parent, fromCurrentUser);
95
+ }
96
+
97
+ /**
98
+ * Generates a unique identifier for the form.
99
+ * @private
100
+ * @returns {String} - Returns a unique identifier
101
+ */
102
+ _getUID() {
103
+ return `comments-${new Date().setUTCMilliseconds()}-${Math.floor(Math.random() * 10000000)}`;
104
+ }
105
+
106
+ /**
107
+ * Initializes the comments for the given parent element.
108
+ * @private
109
+ * @param {jQuery} $parent The parent element to initialize.
110
+ * @returns {Void} - Returns nothing
111
+ */
112
+ _initializeComments($parent) {
113
+ $(".add-comment", $parent).each((_i, el) => {
114
+ const $add = $(el);
115
+ const $form = $("form", $add);
116
+ const $opinionButtons = $(".opinion-toggle .button", $add);
117
+ const $text = $("textarea", $form);
118
+
119
+ $opinionButtons.on("click.decidim-comments", this._onToggleOpinion);
120
+ $text.on("input.decidim-comments", this._onTextInput);
121
+
122
+ $(document).trigger("attach-mentions-element", [$text.get(0)]);
123
+
124
+ $form.on("submit.decidim-comments", () => {
125
+ const $submit = $("button[type='submit']", $form);
126
+
127
+ $submit.attr("disabled", "disabled");
128
+ this._stopPolling();
129
+ });
130
+
131
+ if ($text.length && $text.get(0) !== null) {
132
+ // Attach event to the DOM node, instead of the jQuery object
133
+ $text.get(0).addEventListener("emoji.added", this._onTextInput);
134
+ }
135
+ });
136
+
137
+ this._pollComments();
138
+ }
139
+
140
+ /**
141
+ * Adds the given comment element to the given target element and
142
+ * initializes it.
143
+ * @private
144
+ * @param {jQuery} $target - The target element to add the comment to.
145
+ * @param {jQuery} $container - The comment container element to add.
146
+ * @returns {Void} - Returns nothing
147
+ */
148
+ _addComment($target, $container) {
149
+ let $comment = $(".comment", $container);
150
+ if ($comment.length < 1) {
151
+ // In case of a reply
152
+ $comment = $container;
153
+ }
154
+ this.lastCommentId = parseInt($comment.data("comment-id"), 10);
155
+
156
+ $target.append($container);
157
+ $container.foundation();
158
+ this._initializeComments($container);
159
+ createCharacterCounter($(".add-comment textarea", $container));
160
+ $container.find('a[target="_blank"]').each((_i, elem) => {
161
+ const $link = $(elem);
162
+ $link.data("external-link", new ExternalLink($link));
163
+ });
164
+ updateExternalDomainLinks($container)
165
+ }
166
+
167
+ /**
168
+ * Finalizes the new comment creation after the comment adding finishes
169
+ * successfully.
170
+ * @private
171
+ * @param {jQuery} $parent - The parent comment element to finalize.
172
+ * @param {Boolean} fromCurrentUser - A boolean indicating whether the user
173
+ * herself was the author of the new comment.
174
+ * @returns {Void} - Returns nothing
175
+ */
176
+ _finalizeCommentCreation($parent, fromCurrentUser) {
177
+ if (fromCurrentUser) {
178
+ const $add = $("> .add-comment", $parent);
179
+ const $text = $("textarea", $add);
180
+ const characterCounter = $text.data("remaining-characters-counter");
181
+ $text.val("");
182
+ if (characterCounter) {
183
+ characterCounter.updateStatus();
184
+ }
185
+ if (!$add.parent().is(".comments")) {
186
+ $add.addClass("hide");
187
+ }
188
+ }
189
+
190
+ // Restart the polling
191
+ this._pollComments();
192
+ }
193
+
194
+ /**
195
+ * Sets a timeout to poll new comments.
196
+ * @private
197
+ * @returns {Void} - Returns nothing
198
+ */
199
+ _pollComments() {
200
+ this._stopPolling();
201
+
202
+ this.pollTimeout = setTimeout(() => {
203
+ $.ajax({
204
+ url: this.commentsUrl,
205
+ method: "GET",
206
+ contentType: "application/javascript",
207
+ data: {
208
+ "commentable_gid": this.commentableGid,
209
+ "root_depth": this.rootDepth,
210
+ order: this.order,
211
+ after: this.lastCommentId
212
+ }
213
+ }).done(() => this._pollComments());
214
+ }, this.pollingInterval);
215
+ }
216
+
217
+ /**
218
+ * Stops polling for new comments.
219
+ * @private
220
+ * @returns {Void} - Returns nothing
221
+ */
222
+ _stopPolling() {
223
+ if (this.pollTimeout) {
224
+ clearTimeout(this.pollTimeout);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Sets the loading comments element visible in the view.
230
+ * @private
231
+ * @returns {Void} - Returns nothing
232
+ */
233
+ _setLoading() {
234
+ const $container = $("> .comments-container", this.$element);
235
+ $("> .comments", $container).addClass("hide");
236
+ $("> .loading-comments", $container).removeClass("hide");
237
+ }
238
+
239
+ /**
240
+ * Event listener for the ordering links.
241
+ * @private
242
+ * @returns {Void} - Returns nothing
243
+ */
244
+ _onInitOrder() {
245
+ this._stopPolling();
246
+ this._setLoading();
247
+ }
248
+
249
+ /**
250
+ * Event listener for the opinion toggle buttons.
251
+ * @private
252
+ * @param {Event} ev - The event object.
253
+ * @returns {Void} - Returns nothing
254
+ */
255
+ _onToggleOpinion(ev) {
256
+ let $btn = $(ev.target);
257
+ if (!$btn.is(".button")) {
258
+ $btn = $btn.parents(".button");
259
+ }
260
+
261
+ const $add = $btn.closest(".add-comment");
262
+ const $form = $("form", $add);
263
+ const $opinionButtons = $(".opinion-toggle .button", $add);
264
+ const $selectedState = $(".opinion-toggle .selected-state", $add);
265
+ const $alignment = $(".alignment-input", $form);
266
+
267
+ $opinionButtons.removeClass("is-active").attr("aria-pressed", "false");
268
+ $btn.addClass("is-active").attr("aria-pressed", "true");
269
+
270
+ if ($btn.is(".opinion-toggle--ok")) {
271
+ $alignment.val(1);
272
+ } else if ($btn.is(".opinion-toggle--meh")) {
273
+ $alignment.val(0);
274
+ } else if ($btn.is(".opinion-toggle--ko")) {
275
+ $alignment.val(-1);
276
+ }
277
+
278
+ // Announce the selected state for the screen reader
279
+ $selectedState.text($btn.data("selected-label"));
280
+ }
281
+
282
+ /**
283
+ * Event listener for the comment field text input.
284
+ * @private
285
+ * @param {Event} ev - The event object.
286
+ * @returns {Void} - Returns nothing
287
+ */
288
+ _onTextInput(ev) {
289
+ const $text = $(ev.target);
290
+ const $add = $text.closest(".add-comment");
291
+ const $form = $("form", $add);
292
+ const $submit = $("button[type='submit']", $form);
293
+
294
+ if ($text.val().length > 0) {
295
+ $submit.removeAttr("disabled");
296
+ } else {
297
+ $submit.attr("disabled", "disabled");
298
+ }
299
+ }
300
+ }