katalyst-content 2.2.0 → 2.3.0

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 (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