katalyst-content 2.2.0 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/katalyst/content.esm.js +52 -1
  3. data/app/assets/builds/katalyst/content.js +52 -1
  4. data/app/assets/builds/katalyst/content.min.js +1 -1
  5. data/app/assets/builds/katalyst/content.min.js.map +1 -1
  6. data/app/assets/stylesheets/katalyst/content/editor/_index.scss +3 -27
  7. data/app/assets/stylesheets/katalyst/content/editor/_item-actions.scss +4 -0
  8. data/app/assets/stylesheets/katalyst/content/editor/_new-items.scss +4 -0
  9. data/app/assets/stylesheets/katalyst/content/editor/_table.scss +42 -0
  10. data/app/assets/stylesheets/katalyst/content/editor/_variables.scss +26 -0
  11. data/app/components/katalyst/content/editor/item_editor_component.rb +4 -0
  12. data/app/controllers/katalyst/content/tables_controller.rb +24 -0
  13. data/app/helpers/katalyst/content/editor_helper.rb +2 -0
  14. data/app/helpers/katalyst/content/frontend_helper.rb +2 -0
  15. data/app/helpers/katalyst/content/table_helper.rb +143 -0
  16. data/app/javascript/content/application.js +5 -0
  17. data/app/javascript/content/editor/list_controller.js +2 -1
  18. data/app/javascript/content/editor/table_controller.js +47 -0
  19. data/app/models/katalyst/content/content.rb +2 -0
  20. data/app/models/katalyst/content/table.rb +55 -0
  21. data/app/models/katalyst/content/tables/importer.rb +151 -0
  22. data/app/views/katalyst/content/items/edit.html.erb +5 -0
  23. data/app/views/katalyst/content/tables/_table.html+form.erb +53 -0
  24. data/app/views/katalyst/content/tables/_table.html.erb +3 -0
  25. data/app/views/katalyst/content/tables/update.turbo_stream.erb +3 -0
  26. data/config/locales/en.yml +2 -0
  27. data/config/routes.rb +1 -0
  28. data/lib/katalyst/content/config.rb +9 -0
  29. data/spec/factories/katalyst/content/items.rb +22 -0
  30. metadata +12 -2
@@ -0,0 +1,26 @@
1
+ $grey-light: #f4f4f4 !default;
2
+ $grey: #ececec !default;
3
+ $grey-dark: #999 !default;
4
+ $table-hover-background: #fff0eb !default;
5
+ $primary-color: #ff521f !default;
6
+
7
+ $row-inset: 2rem !default;
8
+ $row-height: 3rem !default;
9
+
10
+ $table-header-color: $grey !default;
11
+ $row-background-color: $grey-light !default;
12
+ $row-hover-color: $table-hover-background !default;
13
+ $icon-active-color: $primary-color !default;
14
+ $icon-passive-color: $grey-dark !default;
15
+
16
+ $status-published-background-color: #ebf9eb !default;
17
+ $status-published-border-color: #4dd45c !default;
18
+ $status-published-color: #4dd45c !default;
19
+
20
+ $status-draft-background-color: #fefaf3 !default;
21
+ $status-draft-border-color: #ffa800 !default;
22
+ $status-draft-color: #ffa800 !default;
23
+
24
+ $status-dirty-background-color: #eee !default;
25
+ $status-dirty-border-color: #888 !default;
26
+ $status-dirty-color: #aaa !default;
@@ -10,6 +10,10 @@ module Katalyst
10
10
  def prefix_partial_path_with_controller_namespace
11
11
  false
12
12
  end
13
+
14
+ def content_routes
15
+ katalyst_content
16
+ end
13
17
  end
14
18
 
15
19
  def call
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ class TablesController < ItemsController
6
+ def update
7
+ item.attributes = item_params
8
+
9
+ if item.valid?
10
+ render :update, locals: { item_editor: }
11
+ else
12
+ render :update, locals: { item_editor: }, status: :unprocessable_entity
13
+ end
14
+ end
15
+ alias_method :create, :update
16
+
17
+ private
18
+
19
+ def item_editor
20
+ editor.item_editor(item:)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -3,6 +3,8 @@
3
3
  module Katalyst
4
4
  module Content
5
5
  module EditorHelper
6
+ include TableHelper
7
+
6
8
  using Katalyst::HtmlAttributes::HasHtmlAttributes
7
9
 
8
10
  def content_editor_rich_text_attributes(attributes = {})
@@ -3,6 +3,8 @@
3
3
  module Katalyst
4
4
  module Content
5
5
  module FrontendHelper
6
+ include TableHelper
7
+
6
8
  # Render all items from a content version as HTML
7
9
  # @param version [Katalyst::Content::Version] The content version to render
8
10
  # @return [ActiveSupport::SafeBuffer,String,nil] Content as HTML
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module TableHelper
6
+ mattr_accessor(:sanitizer, default: Rails::HTML5::Sanitizer.safe_list_sanitizer.new)
7
+ mattr_accessor(:scrubber)
8
+
9
+ # Normalize table content and render to an HTML string (un-sanitised).
10
+ #
11
+ # @param table [Katalyst::Content::Table]
12
+ # @param heading [Boolean] whether to add the heading as a caption
13
+ # @return [String] un-sanitised HTML as text
14
+ def normalize_content_table(table, heading: true)
15
+ TableNormalizer.new(table, heading:).to_html
16
+ end
17
+
18
+ # Sanitize table content and render to an HTML string (un-sanitised).
19
+ #
20
+ # @param content [String] un-sanitised HTML as text
21
+ # @return [ActiveSupport::SafeBuffer] sanitised HTML
22
+ def sanitize_content_table(content)
23
+ sanitizer.sanitize(
24
+ content,
25
+ tags: content_table_allowed_tags,
26
+ attributes: content_table_allowed_attributes,
27
+ scrubber:,
28
+ ).html_safe # rubocop:disable Rails/OutputSafety
29
+ end
30
+
31
+ private
32
+
33
+ def content_table_allowed_tags
34
+ Katalyst::Content::Config.table_sanitizer_allowed_tags
35
+ end
36
+
37
+ def content_table_allowed_attributes
38
+ Katalyst::Content::Config.table_sanitizer_allowed_attributes
39
+ end
40
+
41
+ # rubocop:disable Rails/HelperInstanceVariable
42
+ class TableNormalizer
43
+ attr_reader :node, :heading_rows, :heading_columns
44
+
45
+ delegate_missing_to :@node
46
+
47
+ def initialize(table, heading: true)
48
+ @table = table
49
+ @heading = heading
50
+
51
+ root = ActionText::Fragment.from_html(table.content&.body&.to_html || "").source
52
+ @node = root.name == "table" ? root : root.at_css("table")
53
+
54
+ return unless @node
55
+
56
+ @heading_rows = @table.heading_rows.clamp(0..css("tr").length)
57
+ @heading_columns = @table.heading_columns.clamp(0..)
58
+ end
59
+
60
+ def set_caption!
61
+ return if at_css("caption")
62
+
63
+ prepend_child("<caption>#{@table.heading}</caption>")
64
+ end
65
+
66
+ # move cells between thead and tbody based on heading_rows
67
+ def set_header_rows!
68
+ rows = node.css("tr")
69
+
70
+ if heading_rows > thead.css("tr").count
71
+ rows.slice(0, heading_rows).reject { |row| row.parent == thead }.each do |row|
72
+ thead.add_child(row)
73
+ end
74
+ else
75
+ rows.slice(heading_rows, rows.length).reject { |row| row.parent == tbody }.reverse_each do |row|
76
+ tbody.prepend_child(row)
77
+ end
78
+ end
79
+ end
80
+
81
+ # convert cells between th and td based on heading_columns
82
+ def set_header_columns!
83
+ matrix = []
84
+
85
+ css("tr").each_with_index do |row, y|
86
+ row.css("td, th").each_with_index do |cell, x|
87
+ # step right until we find an empty cell
88
+ x += 1 until matrix.dig(y, x).nil?
89
+
90
+ # update the type of the cell based on the heading configuration
91
+ set_cell_type!(cell, y, x)
92
+
93
+ # record the coordinates that this cell occupies in the matrix
94
+ # e.g. colspan=2 rowspan=3 would occupy 6 cells
95
+ row_range(cell, y).each do |ty|
96
+ col_range(cell, x).each do |tx|
97
+ matrix[ty] ||= []
98
+ matrix[ty][tx] = cell.text
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ def set_cell_type!(cell, row, col)
106
+ cell.name = col < heading_columns || row < heading_rows ? "th" : "td"
107
+ end
108
+
109
+ def to_html
110
+ return "" if @node.nil?
111
+
112
+ set_caption! if @heading && @table.heading.present? && !@table.heading_none?
113
+ set_header_rows!
114
+ set_header_columns!
115
+
116
+ @node.to_html
117
+ end
118
+
119
+ private
120
+
121
+ def thead
122
+ @thead ||= at_css("thead") || tbody.add_previous_sibling("<thead>").first
123
+ end
124
+
125
+ def tbody
126
+ @tbody ||= at_css("tbody") || add_child("<tbody>").last
127
+ end
128
+
129
+ def col_range(cell, from)
130
+ colspan = cell.attributes["colspan"]&.value&.to_i || 1
131
+ (from..).take(colspan)
132
+ end
133
+
134
+ def row_range(cell, from)
135
+ rowspan = cell.attributes["rowspan"]&.value&.to_i || 1
136
+ (from..).take(rowspan)
137
+ end
138
+ end
139
+
140
+ # rubocop:enable Rails/HelperInstanceVariable
141
+ end
142
+ end
143
+ end
@@ -3,6 +3,7 @@ import ItemController from "./editor/item_controller";
3
3
  import ListController from "./editor/list_controller";
4
4
  import NewItemController from "./editor/new_item_controller";
5
5
  import StatusBarController from "./editor/status_bar_controller";
6
+ import TableController from "./editor/table_controller";
6
7
  import TrixController from "./editor/trix_controller";
7
8
 
8
9
  const Definitions = [
@@ -26,6 +27,10 @@ const Definitions = [
26
27
  identifier: "content--editor--status-bar",
27
28
  controllerConstructor: StatusBarController,
28
29
  },
30
+ {
31
+ identifier: "content--editor--table",
32
+ controllerConstructor: TableController,
33
+ },
29
34
  {
30
35
  identifier: "content--editor--trix",
31
36
  controllerConstructor: TrixController,
@@ -84,7 +84,8 @@ export default class ListController extends Controller {
84
84
  this.enterCount <= 0 &&
85
85
  this.dragItem.dataset.hasOwnProperty("newItem")
86
86
  ) {
87
- this.cancelDrag(event);
87
+ this.dragItem.remove();
88
+ this.reset();
88
89
  }
89
90
  }
90
91
 
@@ -0,0 +1,47 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class TableController extends Controller {
4
+ static targets = ["content", "input", "update"];
5
+
6
+ constructor(config) {
7
+ super(config);
8
+
9
+ this.observer = new MutationObserver(this.change);
10
+ }
11
+
12
+ connect() {
13
+ this.observer.observe(this.contentTarget, {
14
+ attributes: true,
15
+ childList: true,
16
+ characterData: true,
17
+ subtree: true,
18
+ });
19
+ }
20
+
21
+ disconnect() {
22
+ this.observer.disconnect();
23
+ }
24
+
25
+ change = (mutations) => {
26
+ this.inputTarget.value = this.table.outerHTML;
27
+ };
28
+
29
+ update = () => {
30
+ this.updateTarget.click();
31
+ };
32
+
33
+ paste = (e) => {
34
+ e.preventDefault();
35
+
36
+ this.inputTarget.value = e.clipboardData.getData("text/html");
37
+
38
+ this.update();
39
+ };
40
+
41
+ /**
42
+ * @returns {HTMLTableElement} The table element from the content target
43
+ */
44
+ get table() {
45
+ return this.contentTarget.querySelector("table");
46
+ }
47
+ }
@@ -7,6 +7,8 @@ module Katalyst
7
7
 
8
8
  validates :content, presence: true
9
9
 
10
+ default_scope { with_rich_text_content }
11
+
10
12
  def initialize_copy(source)
11
13
  super
12
14
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ class Table < Item
6
+ has_rich_text :content
7
+
8
+ attribute :heading_rows, :integer
9
+ attribute :heading_columns, :integer
10
+
11
+ validates :content, presence: true
12
+
13
+ default_scope { with_rich_text_content }
14
+
15
+ after_initialize :set_defaults
16
+
17
+ def initialize_copy(source)
18
+ super
19
+
20
+ content.body = source.content&.body if source.content.is_a?(ActionText::RichText)
21
+ end
22
+
23
+ def self.permitted_params
24
+ super + %i[content heading_rows heading_columns]
25
+ end
26
+
27
+ def valid?(context = nil)
28
+ super(context)
29
+ end
30
+
31
+ def to_plain_text
32
+ content.to_plain_text if visible?
33
+ end
34
+
35
+ def content=(value)
36
+ Tables::Importer.call(self, value)
37
+
38
+ set_defaults
39
+
40
+ content
41
+ end
42
+
43
+ private
44
+
45
+ def set_defaults
46
+ super
47
+
48
+ if content.present? && (fragment = content.body.fragment)
49
+ self.heading_rows ||= fragment.find_all("thead > tr").count
50
+ self.heading_columns ||= fragment.find_all("tbody > tr:first-child > th").count
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module Tables
6
+ class Importer
7
+ include ActiveModel::Model
8
+
9
+ delegate :config, to: Katalyst::Content::Config
10
+
11
+ delegate_missing_to :@node
12
+
13
+ attr_reader :table
14
+
15
+ # Update a table from an HTML5 fragment and apply normalisation rules.
16
+ #
17
+ # @param table [Katalyst::Content::Table] the table to update
18
+ # @param value [Nokogiri::XML::Node, ActionText::RichText, String] the provided HTML fragment
19
+ def self.call(table, value)
20
+ new(table).call(wrap(value))
21
+ end
22
+
23
+ def self.wrap(value)
24
+ case value
25
+ when Nokogiri::XML::Node
26
+ value
27
+ when ActionText::RichText
28
+ # clone body to avoid modifying the original
29
+ Nokogiri::HTML5.fragment(value.body.to_html)
30
+ else
31
+ Nokogiri::HTML5.fragment(value.to_s)
32
+ end
33
+ end
34
+
35
+ def initialize(table)
36
+ super()
37
+
38
+ @table = table
39
+ end
40
+
41
+ def call(fragment)
42
+ @node = fragment.name == "table" ? fragment : fragment.at_css("table")
43
+
44
+ unless @node&.name == "table"
45
+ table.content.body = nil
46
+ return self
47
+ end
48
+
49
+ # Convert b and i to strong and em
50
+ normalize_emphasis!
51
+
52
+ # Convert `td > strong` to th headings
53
+ promote_header_cells!
54
+
55
+ # Promote any rows with only <th> cells to <thead>
56
+ # This captures the pattern where a table has a header row but its
57
+ # in the tbody instead of the thead
58
+ promote_header_rows!
59
+
60
+ # Promote first row to caption if it only has one cell.
61
+ # This captures the pattern where a table has as single cell that spans
62
+ # the entire width of the table, and is used as a heading.
63
+ promote_header_row_caption! if header_row_caption?
64
+
65
+ # Update the table heading with the caption, if present, and remove from table
66
+ set_heading! if caption?
67
+
68
+ # Update the table's content with the normalized HTML.
69
+ table.content.body = to_html
70
+
71
+ table
72
+ end
73
+
74
+ def caption?
75
+ at_css("caption")&.text&.present?
76
+ end
77
+
78
+ def header_row_caption?
79
+ !at_css("caption") &&
80
+ (tr = at_css("thead > tr:first-child"))&.elements&.count == 1 &&
81
+ (tr.elements.first.attributes["colspan"]&.value&.to_i&.> 1)
82
+ end
83
+
84
+ def normalize_emphasis!
85
+ traverse do |node|
86
+ case node.name
87
+ when "b"
88
+ node.name = "strong"
89
+ when "i"
90
+ node.name = "em"
91
+ else
92
+ node
93
+ end
94
+ end
95
+ end
96
+
97
+ def promote_header_cells!
98
+ css("td:has(strong)").each { |cell| promote_cell_to_heading!(cell) }
99
+ end
100
+
101
+ # Converts a `td > strong` cell to a `th` cell by updating the cell name
102
+ # and replacing the cell's content with the strong tag's content.
103
+ def promote_cell_to_heading!(cell)
104
+ strong = cell.at_css("strong")
105
+
106
+ return unless cell.text.strip == strong.text.strip
107
+
108
+ cell.name = "th"
109
+
110
+ # remove strong by promoting its children
111
+ strong.before(strong.children)
112
+ strong.remove
113
+ end
114
+
115
+ def promote_header_rows!
116
+ css("tbody > tr").each do |tr|
117
+ break unless tr.elements.all? { |td| td.name == "th" }
118
+
119
+ promote_row_to_head!(tr)
120
+ end
121
+ end
122
+
123
+ # Moves a row from <tbody> to <thead>.
124
+ # If the table doesn't have a <thead>, one will be created.
125
+ def promote_row_to_head!(row)
126
+ at_css("tbody").before("<thead></thead>") unless at_css("thead")
127
+ at_css("thead") << row
128
+ end
129
+
130
+ # Promotes the first row to a caption if it only has one cell.
131
+ def promote_header_row_caption!
132
+ tr = at_css("thead > tr:first-child")
133
+ cell = tr.elements.first
134
+ tr.remove
135
+ thead = at_css("thead")
136
+ thead.before("<caption></caption>")
137
+ thead.remove if thead.elements.empty?
138
+ at_css("caption").inner_html = cell.inner_html.strip
139
+ end
140
+
141
+ # Set heading from caption and remove the caption from the table.
142
+ def set_heading!
143
+ caption = at_css("caption")
144
+ table.heading = caption.text.strip
145
+ table.heading_style = "default"
146
+ caption.remove
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -1,3 +1,8 @@
1
+ <%# we're going to submit using multiple paths, turbo needs CSRF header token %>
2
+ <% content_for :head do %>
3
+ <%= csrf_meta_tags %>
4
+ <% end %>
5
+
1
6
  <%= render Kpop::ModalComponent.new(title: item_editor.title, layout: "side-panel") do %>
2
7
  <%= render item_editor %>
3
8
  <% end %>
@@ -0,0 +1,53 @@
1
+ <%= form_with model: table, scope: :item, url: path, data: {
2
+ controller: "content--editor--table",
3
+ } do |form| %>
4
+ <%= render "hidden_fields", form: %>
5
+ <%= render "form_errors", form: %>
6
+
7
+ <div class="field">
8
+ <%= form.label :heading %>
9
+ <%= form.text_field :heading %>
10
+ </div>
11
+
12
+ <div class="field">
13
+ <%= form.label :heading_style %>
14
+ <%= form.collection_radio_buttons :heading_style, Katalyst::Content.config.heading_styles, :itself, :itself %>
15
+ </div>
16
+
17
+ <div class="field">
18
+ <%= form.label :background %>
19
+ <%= form.select :background, Katalyst::Content.config.backgrounds %>
20
+ </div>
21
+
22
+ <div class="field">
23
+ <%= form.label :visible %>
24
+ <%= form.check_box :visible %>
25
+ </div>
26
+
27
+ <div class="field">
28
+ <%= form.label :content %>
29
+ <% content = sanitize_content_table(normalize_content_table(form.object, heading: false)) %>
30
+ <div contenteditable="true"
31
+ data-content--editor--table-target="content"
32
+ data-action="paste->content--editor--table#paste">
33
+ <%= content %>
34
+ </div>
35
+ <%= form.hidden_field :content, value: content, data: { content__editor__table_target: "input" } %>
36
+ </div>
37
+
38
+ <div class="field">
39
+ <%= form.label :heading_rows %>
40
+ <%= form.text_field :heading_rows, type: :number, data: { action: "input->content--editor--table#update" } %>
41
+ </div>
42
+
43
+ <div class="field">
44
+ <%= form.label :heading_columns %>
45
+ <%= form.text_field :heading_columns, type: :number, data: { action: "input->content--editor--table#update" } %>
46
+ </div>
47
+
48
+ <%= form.submit "Done" %>
49
+ <%= link_to "Discard", :back %>
50
+ <%= form.button "Update",
51
+ formaction: table.persisted? ? content_routes.table_path : content_routes.tables_path,
52
+ data: { content__editor__table_target: "update" } %>
53
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <%= content_item_tag table do %>
2
+ <%= sanitize_content_table(normalize_content_table(table)) %>
3
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <%= turbo_stream.replace item_editor.id do %>
2
+ <%= render item_editor %>
3
+ <% end %>
@@ -10,6 +10,8 @@ en:
10
10
  katalyst/content/item:
11
11
  content_type_invalid: file type not supported
12
12
  file_size_out_of_range: must be less than %{max_size}
13
+ katalyst/content/table:
14
+ table_missing: Table is missing or invalid
13
15
  views:
14
16
  katalyst:
15
17
  content:
data/config/routes.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  Katalyst::Content::Engine.routes.draw do
2
2
  resources :direct_uploads, only: :create
3
3
  resources :items
4
+ resources :tables, only: %i[create update]
4
5
  end
@@ -13,6 +13,7 @@ module Katalyst
13
13
  %w[
14
14
  Katalyst::Content::Content
15
15
  Katalyst::Content::Figure
16
+ Katalyst::Content::Table
16
17
  Katalyst::Content::Section
17
18
  Katalyst::Content::Group
18
19
  Katalyst::Content::Column
@@ -26,6 +27,14 @@ module Katalyst
26
27
  config_accessor(:errors_component) { "Katalyst::Content::Editor::ErrorsComponent" }
27
28
 
28
29
  config_accessor(:base_controller) { "ApplicationController" }
30
+
31
+ # Sanitizer
32
+ config_accessor(:table_sanitizer_allowed_tags) do
33
+ %w[table thead tbody tr th td caption a strong em span br p text].freeze
34
+ end
35
+ config_accessor(:table_sanitizer_allowed_attributes) do
36
+ %w[colspan rowspan href].freeze
37
+ end
29
38
  end
30
39
  end
31
40
  end
@@ -39,4 +39,26 @@ FactoryBot.define do
39
39
  factory :katalyst_content_column, class: "Katalyst::Content::Column" do
40
40
  content_item_defaults
41
41
  end
42
+
43
+ factory :katalyst_content_table, class: "Katalyst::Content::Table" do
44
+ content_item_defaults
45
+ heading { "Contacts" }
46
+ heading_style { "none" }
47
+ content { <<~HTML }
48
+ <table>
49
+ <thead>
50
+ <tr>
51
+ <th>Name</th>
52
+ <th>Email</th>
53
+ </tr>
54
+ </thead>
55
+ <tbody>
56
+ <tr>
57
+ <td>John Doe</td>
58
+ <td>john.doe@example.com</td>
59
+ </tr>
60
+ </tbody>
61
+ </table>
62
+ HTML
63
+ end
42
64
  end