decidim-collaborative_texts 0.31.0.rc1

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 (159) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +3 -0
  4. data/app/cells/decidim/collaborative_texts/document_cell.rb +23 -0
  5. data/app/cells/decidim/collaborative_texts/document_l_cell.rb +15 -0
  6. data/app/commands/decidim/collaborative_texts/admin/create_document.rb +25 -0
  7. data/app/commands/decidim/collaborative_texts/admin/publish_document.rb +52 -0
  8. data/app/commands/decidim/collaborative_texts/admin/unpublish_document.rb +44 -0
  9. data/app/commands/decidim/collaborative_texts/admin/update_document.rb +101 -0
  10. data/app/commands/decidim/collaborative_texts/admin/update_document_settings.rb +13 -0
  11. data/app/commands/decidim/collaborative_texts/create_suggestion.rb +28 -0
  12. data/app/commands/decidim/collaborative_texts/rollout.rb +50 -0
  13. data/app/controllers/concerns/decidim/collaborative_texts/admin/filterable.rb +23 -0
  14. data/app/controllers/decidim/collaborative_texts/admin/application_controller.rb +15 -0
  15. data/app/controllers/decidim/collaborative_texts/admin/documents_controller.rb +142 -0
  16. data/app/controllers/decidim/collaborative_texts/application_controller.rb +14 -0
  17. data/app/controllers/decidim/collaborative_texts/documents_controller.rb +57 -0
  18. data/app/controllers/decidim/collaborative_texts/suggestions_controller.rb +55 -0
  19. data/app/events/decidim/collaborative_texts/suggestion_accepted_event.rb +6 -0
  20. data/app/forms/decidim/collaborative_texts/admin/document_form.rb +29 -0
  21. data/app/forms/decidim/collaborative_texts/rollout_form.rb +26 -0
  22. data/app/forms/decidim/collaborative_texts/suggestion_form.rb +42 -0
  23. data/app/helpers/decidim/collaborative_texts/application_helper.rb +20 -0
  24. data/app/models/decidim/collaborative_texts/application_record.rb +10 -0
  25. data/app/models/decidim/collaborative_texts/document.rb +78 -0
  26. data/app/models/decidim/collaborative_texts/suggestion.rb +36 -0
  27. data/app/models/decidim/collaborative_texts/version.rb +35 -0
  28. data/app/packs/entrypoints/decidim_collaborative_texts.js +7 -0
  29. data/app/packs/images/decidim/collaborative_texts/decidim_collaborative_texts.svg +1 -0
  30. data/app/packs/src/decidim/collaborative_texts/document.js +168 -0
  31. data/app/packs/src/decidim/collaborative_texts/editor.js +80 -0
  32. data/app/packs/src/decidim/collaborative_texts/init_documents.js +27 -0
  33. data/app/packs/src/decidim/collaborative_texts/manager.js +106 -0
  34. data/app/packs/src/decidim/collaborative_texts/selection.js +106 -0
  35. data/app/packs/src/decidim/collaborative_texts/suggestion.js +243 -0
  36. data/app/packs/src/decidim/collaborative_texts/suggestions_list.js +103 -0
  37. data/app/packs/src/decidim/collaborative_texts/test/document.test.js +83 -0
  38. data/app/packs/src/decidim/collaborative_texts/test/manager.test.js +149 -0
  39. data/app/packs/src/decidim/collaborative_texts/test/selection.test.js +125 -0
  40. data/app/packs/src/decidim/collaborative_texts/test/suggestions.test.js +233 -0
  41. data/app/packs/src/decidim/collaborative_texts/test/toc.test.js +70 -0
  42. data/app/packs/src/decidim/collaborative_texts/toc.js +48 -0
  43. data/app/packs/stylesheets/decidim/collaborative_texts/collaborative_texts.scss +287 -0
  44. data/app/permissions/decidim/collaborative_texts/admin/permissions.rb +28 -0
  45. data/app/permissions/decidim/collaborative_texts/permissions.rb +36 -0
  46. data/app/presenters/decidim/collaborative_texts/admin_log/document_presenter.rb +54 -0
  47. data/app/presenters/decidim/collaborative_texts/admin_log/suggestion_presenter.rb +62 -0
  48. data/app/presenters/decidim/collaborative_texts/admin_log/suggestion_resource_presenter.rb +20 -0
  49. data/app/presenters/decidim/collaborative_texts/admin_log/version_presenter.rb +53 -0
  50. data/app/presenters/decidim/collaborative_texts/official_author_presenter.rb +11 -0
  51. data/app/presenters/decidim/collaborative_texts/suggestion_presenter.rb +57 -0
  52. data/app/views/decidim/collaborative_texts/admin/documents/_actions.html.erb +82 -0
  53. data/app/views/decidim/collaborative_texts/admin/documents/_document-tr.html.erb +15 -0
  54. data/app/views/decidim/collaborative_texts/admin/documents/_documents-thead.html.erb +7 -0
  55. data/app/views/decidim/collaborative_texts/admin/documents/_draft_options.html.erb +6 -0
  56. data/app/views/decidim/collaborative_texts/admin/documents/_form.html.erb +16 -0
  57. data/app/views/decidim/collaborative_texts/admin/documents/_non_draft_options.html.erb +9 -0
  58. data/app/views/decidim/collaborative_texts/admin/documents/_versions.html.erb +18 -0
  59. data/app/views/decidim/collaborative_texts/admin/documents/edit.html.erb +33 -0
  60. data/app/views/decidim/collaborative_texts/admin/documents/edit_settings.html.erb +18 -0
  61. data/app/views/decidim/collaborative_texts/admin/documents/index.html.erb +32 -0
  62. data/app/views/decidim/collaborative_texts/admin/documents/manage_trash.html.erb +19 -0
  63. data/app/views/decidim/collaborative_texts/admin/documents/new.html.erb +18 -0
  64. data/app/views/decidim/collaborative_texts/admin/settings/_form.html.erb +9 -0
  65. data/app/views/decidim/collaborative_texts/documents/_editor_template.html.erb +15 -0
  66. data/app/views/decidim/collaborative_texts/documents/_manager.html.erb +9 -0
  67. data/app/views/decidim/collaborative_texts/documents/_suggestions_box_item_template.html.erb +33 -0
  68. data/app/views/decidim/collaborative_texts/documents/_suggestions_box_template.html.erb +20 -0
  69. data/app/views/decidim/collaborative_texts/documents/index.html.erb +29 -0
  70. data/app/views/decidim/collaborative_texts/documents/show.html.erb +70 -0
  71. data/config/assets.rb +8 -0
  72. data/config/locales/am-ET.yml +1 -0
  73. data/config/locales/ar.yml +1 -0
  74. data/config/locales/bg.yml +1 -0
  75. data/config/locales/bn-BD.yml +1 -0
  76. data/config/locales/bs-BA.yml +1 -0
  77. data/config/locales/ca-IT.yml +154 -0
  78. data/config/locales/ca.yml +154 -0
  79. data/config/locales/cs.yml +122 -0
  80. data/config/locales/da.yml +1 -0
  81. data/config/locales/de.yml +154 -0
  82. data/config/locales/el.yml +1 -0
  83. data/config/locales/en.yml +154 -0
  84. data/config/locales/eo.yml +1 -0
  85. data/config/locales/es-MX.yml +154 -0
  86. data/config/locales/es-PY.yml +154 -0
  87. data/config/locales/es.yml +154 -0
  88. data/config/locales/et.yml +1 -0
  89. data/config/locales/eu.yml +154 -0
  90. data/config/locales/fa-IR.yml +1 -0
  91. data/config/locales/fi-plain.yml +154 -0
  92. data/config/locales/fi.yml +154 -0
  93. data/config/locales/fr-CA.yml +125 -0
  94. data/config/locales/fr.yml +125 -0
  95. data/config/locales/ga-IE.yml +1 -0
  96. data/config/locales/gl.yml +1 -0
  97. data/config/locales/gn-PY.yml +1 -0
  98. data/config/locales/he-IL.yml +1 -0
  99. data/config/locales/hr.yml +1 -0
  100. data/config/locales/hu.yml +1 -0
  101. data/config/locales/id-ID.yml +1 -0
  102. data/config/locales/is-IS.yml +1 -0
  103. data/config/locales/it.yml +1 -0
  104. data/config/locales/ja.yml +153 -0
  105. data/config/locales/ka-GE.yml +1 -0
  106. data/config/locales/kaa.yml +1 -0
  107. data/config/locales/ko.yml +1 -0
  108. data/config/locales/lb.yml +1 -0
  109. data/config/locales/lo-LA.yml +1 -0
  110. data/config/locales/lt.yml +1 -0
  111. data/config/locales/lv.yml +1 -0
  112. data/config/locales/mt.yml +1 -0
  113. data/config/locales/nl.yml +1 -0
  114. data/config/locales/no.yml +6 -0
  115. data/config/locales/oc-FR.yml +1 -0
  116. data/config/locales/om-ET.yml +1 -0
  117. data/config/locales/pl.yml +1 -0
  118. data/config/locales/pt-BR.yml +1 -0
  119. data/config/locales/pt.yml +1 -0
  120. data/config/locales/ro-RO.yml +89 -0
  121. data/config/locales/ru.yml +1 -0
  122. data/config/locales/si-LK.yml +1 -0
  123. data/config/locales/sk.yml +1 -0
  124. data/config/locales/sl.yml +1 -0
  125. data/config/locales/so-SO.yml +1 -0
  126. data/config/locales/sq-AL.yml +1 -0
  127. data/config/locales/sr-CS.yml +1 -0
  128. data/config/locales/sv.yml +117 -0
  129. data/config/locales/sw-KE.yml +1 -0
  130. data/config/locales/th-TH.yml +1 -0
  131. data/config/locales/ti-ER.yml +1 -0
  132. data/config/locales/tr-TR.yml +69 -0
  133. data/config/locales/uk.yml +1 -0
  134. data/config/locales/val-ES.yml +1 -0
  135. data/config/locales/vi.yml +1 -0
  136. data/config/locales/zh-CN.yml +1 -0
  137. data/config/locales/zh-TW.yml +1 -0
  138. data/db/migrate/20250205215038_create_decidim_collaborative_texts_documents.rb +16 -0
  139. data/db/migrate/20250213113536_create_collaborative_texts_versions.rb +13 -0
  140. data/db/migrate/20250227204839_create_collaborative_texts_suggestions.rb +13 -0
  141. data/db/migrate/20250312140133_add_counter_caches_to_collaborative_texts_documents.rb +13 -0
  142. data/db/migrate/20250408205231_add_counter_caches_to_collaborative_text_versions.rb +8 -0
  143. data/decidim-collaborative_texts.gemspec +36 -0
  144. data/lib/decidim/api/document_input_filter.rb +29 -0
  145. data/lib/decidim/api/document_input_sort.rb +14 -0
  146. data/lib/decidim/api/document_type.rb +31 -0
  147. data/lib/decidim/api/documents_type.rb +39 -0
  148. data/lib/decidim/api/suggestion_type.rb +18 -0
  149. data/lib/decidim/api/version_type.rb +21 -0
  150. data/lib/decidim/collaborative_texts/admin.rb +10 -0
  151. data/lib/decidim/collaborative_texts/admin_engine.rb +34 -0
  152. data/lib/decidim/collaborative_texts/api.rb +12 -0
  153. data/lib/decidim/collaborative_texts/component.rb +54 -0
  154. data/lib/decidim/collaborative_texts/engine.rb +28 -0
  155. data/lib/decidim/collaborative_texts/seeds.rb +117 -0
  156. data/lib/decidim/collaborative_texts/test/factories.rb +80 -0
  157. data/lib/decidim/collaborative_texts/version.rb +9 -0
  158. data/lib/decidim/collaborative_texts.rb +13 -0
  159. metadata +233 -0
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module CollaborativeTexts
5
+ class SuggestionsController < Decidim::CollaborativeTexts::ApplicationController
6
+ include Decidim::FormFactory
7
+ include Decidim::AjaxPermissionHandler
8
+
9
+ helper_method :documents, :document
10
+
11
+ def index
12
+ render json: suggestions_for(document.consolidated_version)
13
+ end
14
+
15
+ def create
16
+ enforce_permission_to :suggest, :collaborative_text
17
+ @form = form(SuggestionForm).from_params(params)
18
+
19
+ CreateSuggestion.call(@form) do
20
+ on(:ok) do
21
+ render json: { message: I18n.t("suggestions.create.success", scope: "decidim.collaborative_texts") }
22
+ end
23
+
24
+ on(:invalid) do
25
+ message = [I18n.t("suggestions.create.invalid", scope: "decidim.collaborative_texts")]
26
+ message.push(@form.errors.full_messages.join(", ")) if @form.errors.any?
27
+ render json: { message: message.join(" ") }, status: :unprocessable_entity
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def suggestions_for(version)
35
+ version.suggestions.map do |suggestion|
36
+ suggestion.presenter.safe_json.merge(
37
+ profileHtml: cell("decidim/author", suggestion.author.presenter).to_s.strip
38
+ )
39
+ end
40
+ end
41
+
42
+ def document
43
+ @document ||= documents.find(params[:document_id])
44
+ end
45
+
46
+ def documents
47
+ @documents ||= if current_user&.admin?
48
+ Document.where(component: current_component)
49
+ else
50
+ Document.published.where(component: current_component)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim::CollaborativeTexts
4
+ class SuggestionAcceptedEvent < Decidim::Events::SimpleEvent
5
+ end
6
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module CollaborativeTexts
5
+ module Admin
6
+ # This class holds a Form to create/update documents from Decidim's admin panel.
7
+ class DocumentForm < Decidim::Form
8
+ include Decidim::TranslatableAttributes
9
+ include Decidim::ApplicationHelper
10
+
11
+ mimic :document
12
+
13
+ attribute :title, String
14
+ attribute :body, Decidim::Attributes::RichText
15
+ attribute :accepting_suggestions, Boolean, default: false
16
+ attribute :draft, Boolean, default: false
17
+ translatable_attribute :announcement, Decidim::Attributes::RichText
18
+
19
+ validates :title, presence: true, etiquette: true, unless: ->(form) { form.title.nil? }
20
+ validates :body, presence: true, unless: ->(form) { form.body.nil? } # no etiquette validation for body as it might trigger false positives
21
+
22
+ def coauthorships
23
+ # ensures the first author is always the organization in case "official?" is ever used
24
+ [Decidim::Coauthorship.new(author: current_organization)]
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module CollaborativeTexts
5
+ class RolloutForm < Decidim::Form
6
+ attribute :body, Decidim::Attributes::RichText
7
+ attribute :draft, Boolean, default: true
8
+ attribute :accepted, Array[Integer], default: []
9
+ attribute :pending, Array[Integer], default: []
10
+
11
+ validates :body, presence: true
12
+
13
+ def document
14
+ @document ||= context[:document]
15
+ end
16
+
17
+ def accepted_suggestions
18
+ @accepted_suggestions ||= document.suggestions.where(id: accepted)
19
+ end
20
+
21
+ def pending_suggestions
22
+ @pending_suggestions ||= document.suggestions.where(id: pending)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module CollaborativeTexts
5
+ class SuggestionForm < Decidim::Form
6
+ include JsonbAttributes
7
+
8
+ mimic :document
9
+
10
+ jsonb_attribute :changeset, [
11
+ [:original, Array[String]],
12
+ [:replace, Array[String]],
13
+ [:firstNode, Integer],
14
+ [:lastNode, Integer]
15
+ ]
16
+ attribute :status, String, default: "pending"
17
+ attribute :document_id, Integer
18
+
19
+ validate :changes_exists
20
+
21
+ alias author current_user
22
+
23
+ def document
24
+ @document ||= ::Decidim::CollaborativeTexts::Document.find(document_id)
25
+ end
26
+
27
+ def document_version
28
+ @document_version ||= document.current_version
29
+ end
30
+
31
+ private
32
+
33
+ def changes_exists
34
+ errors.add(:base, I18n.t("errors.blank_changeset", scope: "decidim.collaborative_texts.suggestions")) if changeset.blank?
35
+ if changeset["firstNode"].to_i.zero? || changeset["lastNode"].to_i.zero?
36
+ errors.add(:base,
37
+ I18n.t("errors.invalid_nodes", scope: "decidim.collaborative_texts.suggestions"))
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module CollaborativeTexts
5
+ # Custom helpers, scoped to the collaborative texts engine.
6
+ module ApplicationHelper
7
+ def component_name
8
+ (defined?(current_component) && translated_attribute(current_component&.name).presence) || t("decidim.components.collaborative_texts.name")
9
+ end
10
+
11
+ def document_i18n
12
+ {
13
+ selectionActive: t("decidim.collaborative_texts.document.status.selection_active"),
14
+ rolloutConfirm: t("decidim.collaborative_texts.document.rollout.confirm"),
15
+ consolidateConfirm: t("decidim.collaborative_texts.document.consolidate.confirm")
16
+ }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module CollaborativeTexts
5
+ # Abstract class from which all models in this engine inherit.
6
+ class ApplicationRecord < ActiveRecord::Base
7
+ self.abstract_class = true
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module CollaborativeTexts
5
+ # The data store for a document in the Decidim::CollaborativeTexts component. It stores a
6
+ # title, description and any other useful information to render a custom
7
+ # document.
8
+ class Document < CollaborativeTexts::ApplicationRecord
9
+ include Decidim::Resourceable
10
+ include Decidim::SoftDeletable
11
+ include Decidim::HasComponent
12
+ include Decidim::Publicable
13
+ include Decidim::Traceable
14
+ include Decidim::Loggable
15
+ include Decidim::Searchable
16
+ include Decidim::Coauthorable
17
+
18
+ component_manifest_name "collaborative_texts"
19
+
20
+ after_save :save_version
21
+
22
+ has_many :document_versions, class_name: "Decidim::CollaborativeTexts::Version", inverse_of: :document, dependent: :destroy
23
+ has_many :suggestions, through: :document_versions
24
+
25
+ validates :title, presence: true
26
+
27
+ scope :enabled_desc, -> { order(arel_table[:accepting_suggestions].desc, arel_table[:created_at].desc) }
28
+
29
+ delegate :organization, :participatory_space, to: :component
30
+ delegate :draft?, :draft, :draft=, :body, :body=, to: :current_version
31
+
32
+ searchable_fields(
33
+ participatory_space: { component: :participatory_space },
34
+ A: :title,
35
+ D: :consolidated_body,
36
+ datetime: :published_at
37
+ )
38
+
39
+ def self.log_presenter_class_for(_log)
40
+ Decidim::CollaborativeTexts::AdminLog::DocumentPresenter
41
+ end
42
+
43
+ # Returns the current version of the document. Currently, the last one
44
+ def current_version
45
+ document_versions.last || document_versions.build
46
+ end
47
+
48
+ def consolidated_version
49
+ document_versions.consolidated.last
50
+ end
51
+
52
+ def consolidated_body
53
+ consolidated_version&.body
54
+ end
55
+
56
+ # The paranoia gem (used in soft-delete) applies the removed status to the "document_versions" association
57
+ # but it does not recursively restore them by default.
58
+ # This model needs to have the document_versions synchronized always
59
+ def restore
60
+ super(recursive: true)
61
+ end
62
+
63
+ def has_suggestions?
64
+ current_version.suggestions.any?
65
+ end
66
+
67
+ def suggestions_enabled?
68
+ published? && accepting_suggestions? && !draft?
69
+ end
70
+
71
+ private
72
+
73
+ def save_version
74
+ current_version.save if current_version.changed?
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module CollaborativeTexts
5
+ # The data store for a document in the Decidim::CollaborativeTexts component. It stores a
6
+ # title, description and any other useful information to render a custom
7
+ # document.
8
+ class Suggestion < CollaborativeTexts::ApplicationRecord
9
+ include Decidim::Traceable
10
+ include Decidim::Authorable
11
+
12
+ after_save :update_document_counters
13
+
14
+ enum :status, [:pending, :accepted, :rejected]
15
+ belongs_to :document_version, class_name: "Decidim::CollaborativeTexts::Version", counter_cache: true, inverse_of: :suggestions
16
+ has_one :document, class_name: "Decidim::CollaborativeTexts::Document", through: :document_version
17
+
18
+ delegate :participatory_space, :organization, to: :document_version
19
+
20
+ def self.log_presenter_class_for(_log)
21
+ Decidim::CollaborativeTexts::AdminLog::SuggestionPresenter
22
+ end
23
+
24
+ def presenter
25
+ @presenter ||= Decidim::CollaborativeTexts::SuggestionPresenter.new(self)
26
+ end
27
+
28
+ private
29
+
30
+ def update_document_counters
31
+ # Increment the counter cache for the document
32
+ document.update_column(:suggestions_count, document.suggestions.count) # rubocop:disable Rails/SkipsModelValidations
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module CollaborativeTexts
5
+ # The data store for a document in the Decidim::CollaborativeTexts component. It stores a
6
+ # title, description and any other useful information to render a custom
7
+ # document.
8
+ class Version < CollaborativeTexts::ApplicationRecord
9
+ include Decidim::Resourceable
10
+ include Decidim::SoftDeletable
11
+ include Decidim::Traceable
12
+ include Decidim::Loggable
13
+
14
+ belongs_to :document, class_name: "Decidim::CollaborativeTexts::Document", counter_cache: :document_versions_count, inverse_of: :document_versions
15
+ has_many :suggestions, class_name: "Decidim::CollaborativeTexts::Suggestion", foreign_key: "document_version_id", dependent: :destroy
16
+
17
+ validates :body, presence: true
18
+ validates :draft, presence: true, uniqueness: { scope: :document_id }, if: :draft
19
+
20
+ default_scope { order(created_at: :asc) }
21
+ scope :consolidated, -> { where(draft: false) }
22
+ scope :draft, -> { where(draft: true) }
23
+
24
+ delegate :participatory_space, :organization, to: :document
25
+
26
+ def self.log_presenter_class_for(_log)
27
+ Decidim::CollaborativeTexts::AdminLog::VersionPresenter
28
+ end
29
+
30
+ def version_number
31
+ @version_number ||= document.document_versions.where(created_at: ..created_at).count
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,7 @@
1
+ import "src/decidim/collaborative_texts/init_documents"
2
+
3
+ // Images
4
+ require.context("../images", true)
5
+
6
+ // CSS
7
+ import "stylesheets/decidim/collaborative_texts/collaborative_texts.scss"
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 2C20.5523 2 21 2.44772 21 3V6.757L19 8.757V4H5V20H19V17.242L21 15.242V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V3C3 2.44772 3.44772 2 4 2H20ZM21.7782 8.80761L23.1924 10.2218L15.4142 18L13.9979 17.9979L14 16.5858L21.7782 8.80761ZM13 12V14H8V12H13ZM16 8V10H8V8H16Z"></path></svg>
@@ -0,0 +1,168 @@
1
+ import Selection from "src/decidim/collaborative_texts/selection";
2
+ import SuggestionsList from "src/decidim/collaborative_texts/suggestions_list";
3
+ import Manager from "src/decidim/collaborative_texts/manager";
4
+
5
+ export default class Document {
6
+ constructor(doc) {
7
+ this.doc = doc;
8
+ this.selecting = false;
9
+ this.applying = false;
10
+ this.active = false;
11
+ this.suggestionsList = null;
12
+ this.templates = {
13
+ suggestionsEditor: window.document.querySelector(this.doc.dataset.collaborativeTextsSuggestionsEditorTemplate),
14
+ suggestionsBox: window.document.querySelector(this.doc.dataset.collaborativeTextsSuggestionsBoxTemplate),
15
+ suggestionsBoxItem: window.document.querySelector(this.doc.dataset.collaborativeTextsSuggestionsBoxItemTemplate)
16
+ }
17
+ try {
18
+ this.active = JSON.parse(this.doc.dataset.collaborativeTextsDocument);
19
+ } catch (_e) {
20
+ console.error("Error parsing collaborativeTextsDocument", this.doc.dataset.collaborativeTextsDocument);
21
+ }
22
+ this.i18n = {};
23
+ try {
24
+ this.i18n = JSON.parse(this.doc.dataset.collaborativeTextsI18n);
25
+ } catch (_e) {
26
+ console.error("Error parsing collaborativeTextsI18n", this.doc.dataset.collaborativeTextsI18n);
27
+ }
28
+ this.csrfToken = document.querySelector("meta[name=csrf-token]") && document.querySelector('meta[name="csrf-token"]').getAttribute("content");
29
+ this.alertWrapper = window.document.querySelector(".collaborative-texts-alert");
30
+ this.alertDiv = this.alertWrapper.querySelector("div");
31
+ this._prepareNodes();
32
+ this._bindManager();
33
+ }
34
+
35
+ // listen to new selections and allows the user to participate in the collaborative text
36
+ enableSuggestions() {
37
+ window.document.addEventListener("selectstart", this._onSelectionStart.bind(this));
38
+ window.document.addEventListener("mouseup", this._onSelectionEnd.bind(this));
39
+ return this;
40
+ }
41
+
42
+ // fetches suggestions from the server and updates the UI with the wraps
43
+ fetchSuggestions() {
44
+ this.suggestionsList = new SuggestionsList(this);
45
+ return this;
46
+ }
47
+
48
+ // show an alert message for 5 seconds
49
+ alert(message) {
50
+ this.alertDiv.textContent = message;
51
+ this.alertWrapper.classList.remove("hidden");
52
+ setTimeout(() => {
53
+ this.alertWrapper.classList.add("hidden");
54
+ }, 5000);
55
+ }
56
+
57
+ // bind the manager to the document
58
+ _bindManager() {
59
+ this.doc.addEventListener("collaborative-texts:applied", this._onApply.bind(this));
60
+ this.doc.addEventListener("collaborative-texts:restored", this._onRestore.bind(this));
61
+ this.doc.addEventListener("collaborative-texts:suggest", this._onSuggest.bind(this));
62
+ }
63
+
64
+ // For all first level nodes of type ELEMENT_NODE, ensure they have a unique id if they do not have one starting with "ct-node-"
65
+ _prepareNodes() {
66
+ this.nodes = [];
67
+ [...this.doc.childNodes].forEach((node) => {
68
+ if (node.nodeType !== Node.ELEMENT_NODE) {
69
+ return;
70
+ }
71
+ if (!node.id || !node.id.startsWith("ct-node-")) {
72
+ node.id = `ct-node-${this.nodes.length + 1}`;
73
+ }
74
+ this.nodes.push(node);
75
+ });
76
+ }
77
+
78
+ _onSelectionStart() {
79
+ this.selecting = true;
80
+ }
81
+
82
+ _onSelectionEnd() {
83
+ if (window.document.getSelection().toString() === "") {
84
+ this.selecting = false;
85
+ return;
86
+ }
87
+ if (!this.selecting) {
88
+ return;
89
+ }
90
+ this.selecting = false;
91
+ this._showEditable();
92
+ }
93
+
94
+ _showEditable() {
95
+ this.selection = this.selection || new Selection(this);
96
+ if (this.applying) {
97
+ return;
98
+ }
99
+ if (this.selection.isEditing()) {
100
+ this.alert(this.i18n.selectionActive);
101
+ this.selection.scrollIntoView();
102
+ return;
103
+ }
104
+ if (this.selection.isValid()) {
105
+ this.selection.clear().wrap().showEditor();
106
+ }
107
+ }
108
+
109
+ _onApply() {
110
+ if (!this.doc.dataset.collaborativeTextsRolloutUrl) {
111
+ return;
112
+ }
113
+ this.applying = true;
114
+ this.manager = this.manager || new Manager(this);
115
+ this.manager.show();
116
+ this.manager.updateCounters(this.suggestionsList.getApplied().length, this.suggestionsList.getPending().length);
117
+ }
118
+
119
+ _onRestore() {
120
+ if (!this.suggestionsList.suggestions.find((suggestion) => suggestion.applied)) {
121
+ this.applying = false;
122
+ this.manager.hide();
123
+ }
124
+ }
125
+
126
+ _sanitizeNodes(nodes) {
127
+ return [...nodes].filter((node) => node).map((node) => (node.nodeType === Node.TEXT_NODE
128
+ ? node.textContent
129
+ : node.outerHTML));
130
+ }
131
+
132
+ _onSuggest(event) {
133
+ let original = this._sanitizeNodes(event.detail.nodes);
134
+ let replace = this._sanitizeNodes(event.detail.replaceNodes);
135
+ fetch(this.doc.dataset.collaborativeTextsSuggestionsUrl, {
136
+ method: "POST",
137
+ headers: {
138
+ "Content-Type": "application/json",
139
+ "X-Requested-With": "XMLHttpRequest",
140
+ "X-CSRF-Token": this.csrfToken
141
+ },
142
+ body: JSON.stringify({
143
+ firstNode: event.detail.firstNode.id.replace(/^ct-node-/, ""),
144
+ lastNode: event.detail.lastNode.id.replace(/^ct-node-/, ""),
145
+ original: original,
146
+ replace: replace
147
+ })
148
+ }).
149
+ then(async (response) => {
150
+ const data = await response.json();
151
+ if (!response.ok) {
152
+ throw new Error(data.message
153
+ ? data.message
154
+ : data);
155
+ }
156
+
157
+ if (this.suggestionsList) {
158
+ this.suggestionsList.destroy();
159
+ }
160
+ this.fetchSuggestions();
161
+ }).
162
+ catch((error) => {
163
+ console.error("Error sending suggestion:", error);
164
+ this.alert(error);
165
+ });
166
+ }
167
+ }
168
+
@@ -0,0 +1,80 @@
1
+ export default class Editor {
2
+ constructor(selection) {
3
+ this.selection = selection;
4
+ this.templates = selection.doc.templates;
5
+ this.doc = selection.doc.doc;
6
+ this.wrapper = selection.wrapper;
7
+ this.nodes = selection.nodes;
8
+ this.firstNode = selection.firstNode;
9
+ this.lastNode = selection.lastNode;
10
+ this.editor = null;
11
+ this.container = null;
12
+ this._createEditor();
13
+ this._setupContainer();
14
+ }
15
+
16
+ destroy() {
17
+ if (!this.editor) {
18
+ return;
19
+ }
20
+ this.editor.remove();
21
+ this.editor = null;
22
+ }
23
+
24
+ _createEditor() {
25
+ this.editor = window.document.createElement("div");
26
+ this.editor.classList.add("collaborative-texts-editor");
27
+ this.editor.innerHTML = this.templates.suggestionsEditor.innerHTML;
28
+ this.saveButton = this.editor.querySelector(".collaborative-texts-button-save");
29
+ this.cancelButton = this.editor.querySelector(".collaborative-texts-button-cancel");
30
+ this.editor.addEventListener("keydown", (event) => {
31
+ if (event.ctrlKey && event.key === "Enter") {
32
+ event.preventDefault();
33
+ this._save();
34
+ }
35
+ });
36
+ this.saveButton.addEventListener("click", this._save.bind(this));
37
+ this.cancelButton.addEventListener("click", this._cancel.bind(this));
38
+ this.wrapper.after(this.editor);
39
+ }
40
+
41
+ _setupContainer() {
42
+ // This in the future should be the tiptap editor
43
+ this.container = this.editor.querySelector(".collaborative-texts-editor-container");
44
+ this.container.innerHTML = this.nodes.map((node) => node.outerHTML).join("");
45
+ this.originalHtml = this.container.innerHTML;
46
+ this.container.contentEditable = true;
47
+ this.container.addEventListener("input", this._change.bind(this));
48
+ this.container.addEventListener("focusout", this._change.bind(this));
49
+ this.container.focus();
50
+ }
51
+
52
+ _change() {
53
+ const newHtml = this.container.innerHTML;
54
+ if (newHtml === this.originalHtml) {
55
+ this.saveButton.classList.add("disabled");
56
+ this.saveButton.disabled = true;
57
+ } else {
58
+ this.saveButton.classList.remove("disabled");
59
+ this.saveButton.disabled = false;
60
+ }
61
+ }
62
+
63
+ _save() {
64
+ const event = new CustomEvent("collaborative-texts:suggest", {
65
+ detail: {
66
+ nodes: this.nodes,
67
+ firstNode: this.firstNode,
68
+ lastNode: this.lastNode,
69
+ replaceNodes: this.container.childNodes
70
+ }
71
+ });
72
+ this.doc.dispatchEvent(event);
73
+ this.selection.clear();
74
+ }
75
+
76
+ _cancel() {
77
+ this.selection.clear();
78
+ }
79
+ }
80
+
@@ -0,0 +1,27 @@
1
+ import Document from "src/decidim/collaborative_texts/document";
2
+ import Toc from "src/decidim/collaborative_texts/toc";
3
+
4
+ window.CollaborativeTextsDocuments = window.CollaborativeTextsDocuments || {};
5
+ window.CollaborativeTextsToc = window.CollaborativeTextsToc || {};
6
+ document.addEventListener("turbo:load", () => {
7
+ const documents = window.document.querySelectorAll("[data-collaborative-texts-document]");
8
+ const tableOfContents = window.document.querySelectorAll("[data-collaborative-texts-toc]");
9
+ documents.forEach((doc) => {
10
+ let document = new Document(doc);
11
+ document.fetchSuggestions();
12
+ if (document.active) {
13
+ document.enableSuggestions();
14
+ }
15
+
16
+ window.CollaborativeTextsDocuments[doc.id] = document;
17
+ });
18
+
19
+ tableOfContents.forEach((tocEl) => {
20
+ let document = window.CollaborativeTextsDocuments[tocEl.dataset.collaborativeTextsToc];
21
+ if (document) {
22
+ let toc = new Toc(tocEl, document.doc);
23
+ toc.render();
24
+ window.CollaborativeTextsToc[tocEl.id] = toc;
25
+ }
26
+ });
27
+ });