keystone_ui-pages 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/app/assets/builds/keystone_ui_pages/editor.js +67 -0
- data/app/controllers/concerns/keystone_ui/pages/edits_pages.rb +32 -0
- data/app/controllers/concerns/keystone_ui/pages/host_checks.rb +25 -0
- data/app/controllers/keystone_ui/pages/manage/base_controller.rb +28 -0
- data/app/controllers/keystone_ui/pages/manage/columns_controller.rb +15 -0
- data/app/controllers/keystone_ui/pages/manage/documents_controller.rb +15 -0
- data/app/controllers/keystone_ui/pages/manage/edit_history_controller.rb +21 -0
- data/app/controllers/keystone_ui/pages/manage/elements_controller.rb +30 -0
- data/app/controllers/keystone_ui/pages/manage/lives_controller.rb +20 -0
- data/app/controllers/keystone_ui/pages/manage/nodes_controller.rb +31 -0
- data/app/controllers/keystone_ui/pages/manage/pages_controller.rb +41 -0
- data/app/controllers/keystone_ui/pages/manage/pdfs_controller.rb +23 -0
- data/app/controllers/keystone_ui/pages/manage/rows_controller.rb +15 -0
- data/app/controllers/keystone_ui/pages/manage/samples_controller.rb +16 -0
- data/app/controllers/keystone_ui/pages/manage/sections_controller.rb +15 -0
- data/app/controllers/keystone_ui/pages/manage/versions_controller.rb +25 -0
- data/app/controllers/keystone_ui/pages/pages_controller.rb +26 -0
- data/app/helpers/keystone_ui/pages/drawing_helper.rb +115 -0
- data/app/helpers/keystone_ui/pages/editor_helper.rb +46 -0
- data/app/helpers/keystone_ui/pages/host_helper.rb +17 -0
- data/app/javascript/keystone_ui_pages/DeviceWidth.jsx +21 -0
- data/app/javascript/keystone_ui_pages/ElementFields.jsx +78 -0
- data/app/javascript/keystone_ui_pages/ElementPalette.jsx +62 -0
- data/app/javascript/keystone_ui_pages/LayoutPanel.jsx +41 -0
- data/app/javascript/keystone_ui_pages/ListField.jsx +49 -0
- data/app/javascript/keystone_ui_pages/LookChoice.jsx +17 -0
- data/app/javascript/keystone_ui_pages/PageEditor.jsx +260 -0
- data/app/javascript/keystone_ui_pages/SampleChoice.jsx +15 -0
- data/app/javascript/keystone_ui_pages/SelectionPath.jsx +19 -0
- data/app/javascript/keystone_ui_pages/SelectionToolbar.jsx +14 -0
- data/app/javascript/keystone_ui_pages/ShowWhen.jsx +32 -0
- data/app/javascript/keystone_ui_pages/ThemeMode.jsx +23 -0
- data/app/javascript/keystone_ui_pages/document.js +10 -0
- data/app/javascript/keystone_ui_pages/editor.jsx +8 -0
- data/app/javascript/keystone_ui_pages/operations.js +32 -0
- data/app/jobs/keystone_ui/pages/pdf_job.rb +11 -0
- data/app/models/keystone_ui/pages/page.rb +55 -0
- data/app/models/keystone_ui/pages/pdf.rb +12 -0
- data/app/models/keystone_ui/pages/version.rb +13 -0
- data/app/views/keystone_ui/pages/manage/pages/index.html.erb +35 -0
- data/app/views/keystone_ui/pages/manage/pages/new.html.erb +13 -0
- data/app/views/keystone_ui/pages/manage/pages/show.html.erb +8 -0
- data/app/views/keystone_ui/pages/manage/versions/index.html.erb +28 -0
- data/app/views/keystone_ui/pages/manage/versions/show.html.erb +11 -0
- data/app/views/keystone_ui/pages/pages/show.html.erb +3 -0
- data/app/views/keystone_ui/pages/pdfs/show.html.erb +12 -0
- data/config/routes.rb +31 -0
- data/db/migrate/20261014120000_create_keystone_ui_pages_pages.rb +17 -0
- data/db/migrate/20261014120001_create_keystone_ui_pages_versions.rb +18 -0
- data/db/migrate/20261015130000_create_keystone_ui_pages_pdfs.rb +14 -0
- data/db/migrate/20261016120000_add_edit_history_to_keystone_ui_pages_pages.rb +8 -0
- data/lib/keystone_ui/pages/catalog.rb +29 -0
- data/lib/keystone_ui/pages/document.rb +29 -0
- data/lib/keystone_ui/pages/engine.rb +25 -0
- data/lib/keystone_ui/pages/keystone_elements.rb +109 -0
- data/lib/keystone_ui/pages/operations.rb +246 -0
- data/lib/keystone_ui/pages/pdf_document.rb +17 -0
- data/lib/keystone_ui/pages/pdfs.rb +20 -0
- data/lib/keystone_ui/pages/printer.rb +43 -0
- data/lib/keystone_ui/pages/values.rb +28 -0
- data/lib/keystone_ui/pages/version.rb +7 -0
- data/lib/keystone_ui/pages.rb +45 -0
- data/lib/keystone_ui-pages.rb +3 -0
- data/the_local/agents/keystone_ui-pages-develop.md +277 -0
- data/the_local/agents/keystone_ui-pages-info.md +71 -0
- data/the_local/agents/keystone_ui-pages-install.md +167 -0
- data/the_local/interface.yml +56 -0
- metadata +169 -0
data/config/routes.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
KeystoneUi::Pages::Engine.routes.draw do
|
|
4
|
+
namespace :manage do
|
|
5
|
+
resources :pages, only: %i[index new create show] do
|
|
6
|
+
post :publish, on: :member
|
|
7
|
+
resources :elements, only: %i[create update] do
|
|
8
|
+
post :bind, on: :member
|
|
9
|
+
end
|
|
10
|
+
resources :sections, only: %i[create]
|
|
11
|
+
resources :columns, only: %i[create]
|
|
12
|
+
resources :rows, only: %i[create]
|
|
13
|
+
resources :nodes, only: %i[update destroy] do
|
|
14
|
+
post :move, on: :member
|
|
15
|
+
post :duplicate, on: :member
|
|
16
|
+
post :condition, on: :member
|
|
17
|
+
end
|
|
18
|
+
resource :document, only: %i[update]
|
|
19
|
+
post :sample, to: "samples#update"
|
|
20
|
+
post :undo, to: "edit_history#undo"
|
|
21
|
+
post :redo, to: "edit_history#redo"
|
|
22
|
+
resource :live, only: %i[show]
|
|
23
|
+
resource :pdf, only: %i[show]
|
|
24
|
+
resources :versions, only: %i[index show] do
|
|
25
|
+
post :restore, on: :member
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
get ":slug", to: "pages#show", as: :page
|
|
31
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateKeystoneUiPagesPages < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :keystone_ui_pages_pages do |t|
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.string :slug, null: false
|
|
8
|
+
t.json :document, null: false
|
|
9
|
+
t.references :owner, polymorphic: true
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :keystone_ui_pages_pages, [ :owner_type, :owner_id, :slug ], unique: true,
|
|
15
|
+
name: "index_keystone_ui_pages_pages_on_owner_and_slug"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateKeystoneUiPagesVersions < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :keystone_ui_pages_versions do |t|
|
|
6
|
+
t.references :page, null: false, foreign_key: { to_table: :keystone_ui_pages_pages }
|
|
7
|
+
t.integer :number, null: false
|
|
8
|
+
t.json :document, null: false
|
|
9
|
+
t.string :status, null: false
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :keystone_ui_pages_versions, [ :page_id, :number ], unique: true
|
|
15
|
+
add_index :keystone_ui_pages_versions, :page_id, unique: true, where: "status = 'live'",
|
|
16
|
+
name: "index_keystone_ui_pages_versions_on_one_live_per_page"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateKeystoneUiPagesPdfs < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :keystone_ui_pages_pdfs do |t|
|
|
6
|
+
t.references :version, null: false, foreign_key: { to_table: :keystone_ui_pages_versions }
|
|
7
|
+
t.string :values_digest, null: false
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :keystone_ui_pages_pdfs, [ :version_id, :values_digest ], unique: true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Pages
|
|
5
|
+
class Catalog
|
|
6
|
+
Element = Data.define(:key, :name, :component, :fields, :body, :icon, :group, :align)
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@elements = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def register(key:, name:, component:, fields:, body: nil, icon: nil, group: nil, align: nil)
|
|
13
|
+
@elements[key.to_sym] = Element.new(key: key.to_sym, name: name, component: component, fields: fields, body: body, icon: icon, group: group, align: align)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def leave_out(*keys)
|
|
17
|
+
keys.each { |key| @elements.delete(key.to_sym) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def elements
|
|
21
|
+
@elements.values
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find(key)
|
|
25
|
+
@elements[key.to_sym]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module KeystoneUi
|
|
6
|
+
module Pages
|
|
7
|
+
module Document
|
|
8
|
+
SCHEMA_VERSION = 1
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def blank
|
|
13
|
+
{ "schema_version" => SCHEMA_VERSION, "sections" => [ section ] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def section
|
|
17
|
+
{ "id" => SecureRandom.uuid, "rows" => [ row ] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def row
|
|
21
|
+
{ "id" => SecureRandom.uuid, "columns" => [ column ] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def column
|
|
25
|
+
{ "id" => SecureRandom.uuid, "elements" => [] }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "keystone_ui"
|
|
5
|
+
require "keystone_ui-react"
|
|
6
|
+
require "keystone_ui/react/mount_helper"
|
|
7
|
+
|
|
8
|
+
module KeystoneUi
|
|
9
|
+
module Pages
|
|
10
|
+
class Engine < ::Rails::Engine
|
|
11
|
+
isolate_namespace KeystoneUi::Pages
|
|
12
|
+
|
|
13
|
+
initializer "keystone_ui_pages.host_helper" do
|
|
14
|
+
ActiveSupport.on_load(:action_view) { include KeystoneUi::Pages::HostHelper }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "keystone_ui_pages.tailwind" do
|
|
18
|
+
KeystoneUi.configuration.tailwind_sources << root.join("app/views/**/*.erb").to_s
|
|
19
|
+
KeystoneUi.configuration.tailwind_sources << root.join("app/helpers/**/*.rb").to_s
|
|
20
|
+
KeystoneUi.configuration.tailwind_sources << root.join("app/javascript/**/*.{js,jsx}").to_s
|
|
21
|
+
KeystoneUi.configuration.tailwind_sources << root.join("lib/keystone_ui/pages/**/*.rb").to_s
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Pages
|
|
5
|
+
module KeystoneElements
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def register(catalog)
|
|
9
|
+
catalog.register(key: :button, name: "Button", component: :ui_button, icon: :cursor, group: "Basics", align: "start",
|
|
10
|
+
fields: [ { key: :label, label: "Label", default: "Button" }, { key: :href, label: "Address" } ])
|
|
11
|
+
catalog.register(key: :progress, name: "Progress", component: :ui_progress, icon: :progress, group: "Data",
|
|
12
|
+
fields: [ { key: :value, label: "Value", kind: :number, default: "0" }, { key: :max, label: "Out of", kind: :number, default: "100" }, { key: :label, label: "Label", kind: :text, default: "Progress" } ])
|
|
13
|
+
catalog.register(key: :alert, name: "Alert", component: :ui_alert, icon: :alert, group: "Text",
|
|
14
|
+
fields: [ { key: :message, label: "Message", kind: :long_text, default: "Something worth knowing." }, { key: :title, label: "Title", kind: :text },
|
|
15
|
+
{ key: :type, label: "Type", kind: :one_of, options: %w[info success warning error], default: "info" },
|
|
16
|
+
{ key: :dismissible, label: "Can be dismissed", kind: :yes_or_no } ])
|
|
17
|
+
catalog.register(key: :code, name: "Code", component: :ui_code, icon: :code, group: "Text", body: :code,
|
|
18
|
+
fields: [ { key: :code, label: "Code", kind: :long_text, default: "puts \"Hello\"" }, { key: :language, label: "Language", kind: :text },
|
|
19
|
+
{ key: :caption, label: "Caption", kind: :text } ])
|
|
20
|
+
catalog.register(key: :heading, name: "Heading", component: :ui_heading, icon: :heading, group: "Text",
|
|
21
|
+
fields: [ { key: :text, label: "Text", kind: :text, default: "Heading" },
|
|
22
|
+
{ key: :size, label: "Size", kind: :one_of, options: %w[sm md lg], default: "md" },
|
|
23
|
+
{ key: :note, label: "Note", kind: :text } ])
|
|
24
|
+
catalog.register(key: :label, name: "Label", component: :ui_caption, icon: :tag, group: "Text",
|
|
25
|
+
fields: [ { key: :text, label: "Text", kind: :text, default: "Label" } ])
|
|
26
|
+
catalog.register(key: :paragraph, name: "Paragraph", component: :ui_paragraph, icon: :text, group: "Text",
|
|
27
|
+
fields: [ { key: :text, label: "Text", kind: :long_text, default: "Write the text of this paragraph here." } ])
|
|
28
|
+
catalog.register(key: :hero, name: "Hero", component: :ui_hero, icon: :heading, group: "Text",
|
|
29
|
+
fields: [ { key: :title, label: "Title", kind: :text, default: "Headline" },
|
|
30
|
+
{ key: :subtitle, label: "Subtitle", kind: :long_text },
|
|
31
|
+
{ key: :badge, label: "Badge", kind: :text },
|
|
32
|
+
{ key: :layout, label: "Layout", kind: :one_of, options: %w[split centered], default: "split" },
|
|
33
|
+
{ key: :height, label: "Height", kind: :one_of, options: %w[content tall full_screen], default: "tall",
|
|
34
|
+
classes: { "content" => "[&_.ks-hero]:min-h-0", "tall" => "[&_.ks-hero]:min-h-[28rem]", "full_screen" => "[&_.ks-hero]:min-h-screen" } } ])
|
|
35
|
+
catalog.register(key: :cta_banner, name: "Call to action", component: :ui_cta_banner, icon: :megaphone, group: "Content", body: :text,
|
|
36
|
+
fields: [ { key: :title, label: "Title", kind: :text, default: "Ready to start?" },
|
|
37
|
+
{ key: :subtitle, label: "Subtitle", kind: :text },
|
|
38
|
+
{ key: :text, label: "Text", kind: :long_text } ])
|
|
39
|
+
catalog.register(key: :card, name: "Card", component: :ui_card, icon: :card, group: "Content",
|
|
40
|
+
fields: [ { key: :title, label: "Title", kind: :text, default: "Card title" },
|
|
41
|
+
{ key: :summary, label: "Summary", kind: :long_text },
|
|
42
|
+
{ key: :link, label: "Link", kind: :address, default: "#" },
|
|
43
|
+
{ key: :cta, label: "Link text", kind: :text, default: "Read more" } ])
|
|
44
|
+
catalog.register(key: :stat_card, name: "Stat", component: :ui_stat_card, icon: :chart, group: "Data",
|
|
45
|
+
fields: [ { key: :label, label: "Label", kind: :text, default: "Label" },
|
|
46
|
+
{ key: :value, label: "Value", kind: :text, default: "0" },
|
|
47
|
+
{ key: :suffix, label: "Suffix", kind: :text },
|
|
48
|
+
{ key: :variant, label: "Tone", kind: :one_of, options: %w[neutral success danger warning info], default: "neutral" },
|
|
49
|
+
{ key: :note, label: "Note", kind: :text },
|
|
50
|
+
{ key: :plain, label: "Plain", kind: :yes_or_no },
|
|
51
|
+
{ key: :fallback, label: "When missing", kind: :text, default: "N/A" } ])
|
|
52
|
+
catalog.register(key: :figure, name: "Figure", component: :ui_figure, icon: :number, group: "Data",
|
|
53
|
+
fields: [ { key: :text, label: "Text", kind: :text, default: "0" },
|
|
54
|
+
{ key: :tone, label: "Tone", kind: :one_of, options: %w[neutral success danger], default: "neutral" } ])
|
|
55
|
+
catalog.register(key: :info, name: "Info", component: :ui_info, icon: :info, group: "Text", body: :detail,
|
|
56
|
+
fields: [ { key: :summary, label: "Summary", kind: :text, default: "More information" },
|
|
57
|
+
{ key: :detail, label: "Detail", kind: :long_text } ])
|
|
58
|
+
catalog.register(key: :badge, name: "Badge", component: :ui_badge, icon: :tag, group: "Basics", align: "start",
|
|
59
|
+
fields: [ { key: :label, label: "Label", kind: :text, default: "Badge" },
|
|
60
|
+
{ key: :variant, label: "Tone", kind: :one_of, options: %w[neutral success danger warning info], default: "neutral" } ])
|
|
61
|
+
catalog.register(key: :copy_button, name: "Copy button", component: :ui_copy_button, icon: :copy, group: "Basics", align: "start",
|
|
62
|
+
fields: [ { key: :text, label: "Text to copy", kind: :text },
|
|
63
|
+
{ key: :label, label: "Label", kind: :text, default: "Copy" } ])
|
|
64
|
+
catalog.register(key: :feature_grid, name: "Feature grid", component: :ui_feature_grid, icon: :card, group: "Content",
|
|
65
|
+
fields: [ { key: :title, label: "Title", kind: :text, default: "Why teams choose us" },
|
|
66
|
+
{ key: :subtitle, label: "Subtitle", kind: :text },
|
|
67
|
+
{ key: :features, label: "Features", kind: :list,
|
|
68
|
+
entry: [ { key: :title, label: "Title", kind: :text }, { key: :description, label: "Description", kind: :long_text },
|
|
69
|
+
{ key: :icon, label: "Icon", kind: :text, hidden: true,
|
|
70
|
+
default: '<svg viewBox="0 0 24 24" class="size-6 fill-none stroke-current stroke-2"><path d="M5 12l5 5L20 7"/></svg>' } ],
|
|
71
|
+
default: [ { "title" => "Fast", "description" => "Pages load quickly." },
|
|
72
|
+
{ "title" => "Clear", "description" => "Every page reads plainly." },
|
|
73
|
+
{ "title" => "Yours", "description" => "Built from your own components." } ] } ])
|
|
74
|
+
catalog.register(key: :accordion, name: "Accordion", component: :ui_accordion, icon: :code, group: "Text",
|
|
75
|
+
fields: [ { key: :items, label: "Questions", kind: :list,
|
|
76
|
+
entry: [ { key: :question, label: "Question", kind: :text }, { key: :answer, label: "Answer", kind: :long_text } ],
|
|
77
|
+
default: [ { "question" => "What is this?", "answer" => "A question visitors often ask." },
|
|
78
|
+
{ "question" => "How does it work?", "answer" => "The answer opens below its question." } ] } ])
|
|
79
|
+
catalog.register(key: :data_table, name: "Table", component: :ui_data_table, icon: :card, group: "Data",
|
|
80
|
+
fields: [ { key: :items, label: "Rows", kind: :data,
|
|
81
|
+
default: [ { "name" => "First row", "status" => "Open" }, { "name" => "Second row", "status" => "Closed" } ] },
|
|
82
|
+
{ key: :columns, label: "Columns", kind: :list, as: :pairs,
|
|
83
|
+
entry: [ { key: :key, label: "Key", kind: :text }, { key: :label, label: "Heading", kind: :text },
|
|
84
|
+
{ key: :align, label: "Align", kind: :one_of, options: %w[start end] } ],
|
|
85
|
+
default: [ { "key" => "name", "label" => "Name" }, { "key" => "status", "label" => "Status" } ] },
|
|
86
|
+
{ key: :group_by, label: "Group rows by key", kind: :text, as: :key },
|
|
87
|
+
{ key: :group_totals, label: "Group subtotals", kind: :data },
|
|
88
|
+
{ key: :total, label: "Total row", kind: :data } ])
|
|
89
|
+
catalog.register(key: :value_list, name: "Label and value list", component: :ui_value_list, icon: :list, group: "Data",
|
|
90
|
+
fields: [ { key: :rows, label: "Rows", kind: :list,
|
|
91
|
+
entry: [ { key: :label, label: "Label", kind: :text }, { key: :value, label: "Value", kind: :text },
|
|
92
|
+
{ key: :note, label: "Note", kind: :text }, { key: :total, label: "Total", kind: :yes_or_no },
|
|
93
|
+
{ key: :fallback, label: "When missing", kind: :text, default: "N/A" } ],
|
|
94
|
+
default: [ { "label" => "Prepared by", "value" => "Your team" },
|
|
95
|
+
{ "label" => "Prepared for", "value" => "Your client" },
|
|
96
|
+
{ "label" => "Issued", "value" => "1 October" } ] },
|
|
97
|
+
{ key: :align, label: "Values", kind: :one_of, options: %w[start end], default: "start" } ])
|
|
98
|
+
catalog.register(key: :bar_chart, name: "Bar chart", component: :ui_bar_chart, icon: :bars, group: "Data",
|
|
99
|
+
fields: [ { key: :bars, label: "Bars", kind: :data,
|
|
100
|
+
default: [ { "label" => "Mon", "value" => "3" }, { "label" => "Tue", "value" => "5" }, { "label" => "Wed", "value" => "2" },
|
|
101
|
+
{ "label" => "Sat", "value" => "1", "shaded" => true } ] },
|
|
102
|
+
{ key: :show_values, label: "Show values", kind: :yes_or_no, default: "true" } ])
|
|
103
|
+
catalog.register(key: :line_chart, name: "Line chart", component: :ui_line_chart, icon: :chart, group: "Data",
|
|
104
|
+
fields: [ { key: :series, label: "Series", kind: :data, default: [ { "name" => "Visits", "data" => [ 3, 5, 4, 7 ] } ] },
|
|
105
|
+
{ key: :labels, label: "Labels", kind: :data, default: %w[Mon Tue Wed Thu] } ])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "keystone_ui/pages"
|
|
7
|
+
require "keystone_ui/pages/document"
|
|
8
|
+
require "keystone_ui/pages/values"
|
|
9
|
+
|
|
10
|
+
module KeystoneUi
|
|
11
|
+
module Pages
|
|
12
|
+
class InvalidDocument < StandardError; end
|
|
13
|
+
|
|
14
|
+
module Operations
|
|
15
|
+
WIDTHS = %w[1/1 3/4 2/3 1/2 1/3 1/4].freeze
|
|
16
|
+
ARRANGEMENTS = {
|
|
17
|
+
section: { "spacing" => %w[sm md lg] },
|
|
18
|
+
row: { "gap" => %w[sm md lg xl] },
|
|
19
|
+
column: { "wide" => WIDTHS, "narrow" => WIDTHS, "align" => %w[top center bottom] },
|
|
20
|
+
element: { "align" => %w[stretch start center end] }
|
|
21
|
+
}.freeze
|
|
22
|
+
DEFAULTS = {
|
|
23
|
+
section: { "spacing" => "md" },
|
|
24
|
+
row: { "gap" => "md" },
|
|
25
|
+
column: { "narrow" => "1/1", "align" => "top" },
|
|
26
|
+
element: { "align" => "stretch" }
|
|
27
|
+
}.freeze
|
|
28
|
+
SHARES = { 1 => "1/1", 2 => "1/2", 3 => "1/3", 4 => "1/4" }.freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def add(document, key, column_id:, catalog: Pages.catalog)
|
|
33
|
+
defined(catalog, key)
|
|
34
|
+
copied(document).tap do |changed|
|
|
35
|
+
column(changed, column_id)["elements"] << { "id" => SecureRandom.uuid, "key" => key.to_s, "fields" => {} }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def add_section(document)
|
|
40
|
+
copied(document).tap { |changed| changed["sections"] << Document.section }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add_row(document, section_id:)
|
|
44
|
+
copied(document).tap { |changed| node(changed, section_id)["rows"] << Document.row }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add_column(document, row_id:)
|
|
48
|
+
copied(document).tap { |changed| node(changed, row_id)["columns"] << Document.column }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def arrange(document, node_id:, setting:, value:)
|
|
52
|
+
offered_arrangement(kind(document, node_id), setting.to_s, value)
|
|
53
|
+
copied(document).tap do |changed|
|
|
54
|
+
node(changed, node_id)[setting.to_s] = value
|
|
55
|
+
fitting(changed, setting.to_s) if %w[wide narrow].include?(setting.to_s)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fitting(document, screen)
|
|
60
|
+
document["sections"].each.with_index(1) do |section, section_number|
|
|
61
|
+
section["rows"].each.with_index(1) do |row, row_number|
|
|
62
|
+
next if row["columns"].sum { |column| Rational(column[screen] || "0") } <= 1
|
|
63
|
+
|
|
64
|
+
raise InvalidDocument, "Row #{row_number} of section #{section_number} is wider than the page on #{screen} screens"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def offered_arrangement(kind, setting, value)
|
|
70
|
+
return if ARRANGEMENTS.fetch(kind).fetch(setting, []).include?(value)
|
|
71
|
+
|
|
72
|
+
raise InvalidDocument, "A #{kind}'s #{setting} cannot be #{value}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def kind(document, node_id)
|
|
76
|
+
sections = document["sections"]
|
|
77
|
+
return :section if sections.any? { |section| section["id"] == node_id }
|
|
78
|
+
return :row if sections.flat_map { |section| section["rows"] }.any? { |row| row["id"] == node_id }
|
|
79
|
+
return :column if column(document, node_id)
|
|
80
|
+
|
|
81
|
+
:element
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def move(document, node_id:, into:, position:)
|
|
85
|
+
present(document, node_id)
|
|
86
|
+
outside(document, node_id, into)
|
|
87
|
+
copied(document).tap do |changed|
|
|
88
|
+
moved = node(changed, node_id)
|
|
89
|
+
parent_list(changed, node_id).delete(moved)
|
|
90
|
+
children(changed, into).insert(position.to_i, moved)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def outside(document, node_id, into)
|
|
95
|
+
return unless inside(node(document, node_id)).any? { |child| child["id"] == into }
|
|
96
|
+
|
|
97
|
+
raise InvalidDocument, "A #{kind(document, node_id)} cannot be moved into something inside it"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def inside(node)
|
|
101
|
+
children = node.values_at("rows", "columns", "elements").compact.flatten
|
|
102
|
+
children + children.flat_map { |child| inside(child) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def present(document, node_id)
|
|
106
|
+
node(document, node_id) or raise InvalidDocument, "Nothing in this page has the id #{node_id}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parent_list(document, node_id)
|
|
110
|
+
lists(document).find { |list| list.any? { |child| child["id"] == node_id } }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def lists(document)
|
|
114
|
+
sections = document["sections"]
|
|
115
|
+
rows = sections.flat_map { |section| section["rows"] }
|
|
116
|
+
[ sections ] + sections.map { |section| section["rows"] } + rows.map { |row| row["columns"] } + columns(document).map { |column| column["elements"] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def children(document, parent_id)
|
|
120
|
+
return document["sections"] if parent_id.nil?
|
|
121
|
+
|
|
122
|
+
parent = node(document, parent_id)
|
|
123
|
+
parent["rows"] || parent["columns"] || parent["elements"]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def duplicate(document, node_id:)
|
|
127
|
+
present(document, node_id)
|
|
128
|
+
copied(document).tap do |changed|
|
|
129
|
+
siblings = parent_list(changed, node_id)
|
|
130
|
+
original = siblings.find { |child| child["id"] == node_id }
|
|
131
|
+
siblings.insert(siblings.index(original) + 1, renewed(copied(original)))
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def renewed(node)
|
|
136
|
+
node.merge("id" => SecureRandom.uuid).tap do |copy|
|
|
137
|
+
%w[rows columns elements].each { |list| copy[list] = copy[list].map { |child| renewed(child) } if copy.key?(list) }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def remove(document, node_id:)
|
|
142
|
+
present(document, node_id)
|
|
143
|
+
held = held_elements(node(document, node_id))
|
|
144
|
+
raise InvalidDocument, "This #{kind(document, node_id)} holds #{held} #{held == 1 ? 'element' : 'elements'}, so it cannot be removed" if held.positive?
|
|
145
|
+
|
|
146
|
+
copied(document).tap { |changed| parent_list(changed, node_id).reject! { |child| child["id"] == node_id } }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def held_elements(node)
|
|
150
|
+
return 0 if node.key?("key")
|
|
151
|
+
return node["elements"].size if node.key?("elements")
|
|
152
|
+
|
|
153
|
+
(node["rows"] || node["columns"]).sum { |child| held_elements(child) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def show_when(document, node_id:, name:, equals:)
|
|
157
|
+
raise InvalidDocument, "A column cannot be shown or hidden by a value" if kind(document, node_id) == :column
|
|
158
|
+
return copied(document).tap { |changed| node(changed, node_id).delete("show_when") } if name.blank?
|
|
159
|
+
|
|
160
|
+
raise InvalidDocument, "No value is named #{name}" unless Values.label(name)
|
|
161
|
+
|
|
162
|
+
copied(document).tap do |changed|
|
|
163
|
+
node(changed, node_id)["show_when"] = { "value" => name.to_s, "equals" => equals.to_s }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def bind(document, element_id:, field:, name:, fallback: nil, catalog: Pages.catalog)
|
|
168
|
+
raise InvalidDocument, "No value is named #{name}" unless Values.label(name)
|
|
169
|
+
|
|
170
|
+
copied(document).tap do |changed|
|
|
171
|
+
element = element(changed, element_id)
|
|
172
|
+
offered(defined(catalog, element["key"]), field)
|
|
173
|
+
element["fields"][field.to_s] = { "value" => name.to_s, "fallback" => fallback }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def set(document, element_id:, field:, value:, catalog: Pages.catalog)
|
|
178
|
+
copied(document).tap do |changed|
|
|
179
|
+
element = element(changed, element_id)
|
|
180
|
+
offered(defined(catalog, element["key"]), field)
|
|
181
|
+
element["fields"][field.to_s] = value
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def check(document, catalog: Pages.catalog)
|
|
186
|
+
elements(document).each do |element|
|
|
187
|
+
definition = defined(catalog, element["key"])
|
|
188
|
+
element["fields"].each_key { |field| offered(definition, field) }
|
|
189
|
+
end
|
|
190
|
+
document
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def digest(document)
|
|
194
|
+
Digest::SHA256.hexdigest(JSON.generate(sorted(document)))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def sorted(value)
|
|
198
|
+
case value
|
|
199
|
+
when Hash then value.sort_by { |key, _| key.to_s }.to_h { |key, nested| [ key, sorted(nested) ] }
|
|
200
|
+
when Array then value.map { |nested| sorted(nested) }
|
|
201
|
+
else value
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def defined(catalog, key)
|
|
206
|
+
catalog.find(key) or raise InvalidDocument, "No element is registered as #{key}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def offered(definition, field)
|
|
210
|
+
return if definition.fields.any? { |offered| offered[:key].to_s == field.to_s }
|
|
211
|
+
|
|
212
|
+
raise InvalidDocument, "The #{definition.key} element has no #{field} field"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def copied(document)
|
|
216
|
+
JSON.parse(JSON.generate(document))
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def nodes(document)
|
|
220
|
+
sections = document["sections"]
|
|
221
|
+
rows = sections.flat_map { |section| section["rows"] }
|
|
222
|
+
sections + rows + columns(document) + elements(document)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def node(document, node_id)
|
|
226
|
+
nodes(document).find { |node| node["id"] == node_id }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def columns(document)
|
|
230
|
+
document["sections"].flat_map { |section| section["rows"] }.flat_map { |row| row["columns"] }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def column(document, column_id)
|
|
234
|
+
columns(document).find { |column| column["id"] == column_id }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def elements(document)
|
|
238
|
+
columns(document).flat_map { |column| column["elements"] }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def element(document, element_id)
|
|
242
|
+
elements(document).find { |element| element["id"] == element_id }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Pages
|
|
5
|
+
module PdfDocument
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def html(version, values: {})
|
|
9
|
+
KeystoneUi::Pages::PagesController.render(template: "keystone_ui/pages/pdfs/show", layout: false, assigns: { version: version, values: values, stylesheet: stylesheet })
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def stylesheet
|
|
13
|
+
Rails.application.assets.load_path.find(Pages.pdf_stylesheet)&.content.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "keystone_ui/pages/operations"
|
|
4
|
+
require "keystone_ui/pages/pdf_document"
|
|
5
|
+
require "keystone_ui/pages/printer"
|
|
6
|
+
|
|
7
|
+
module KeystoneUi
|
|
8
|
+
module Pages
|
|
9
|
+
module Pdfs
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def for(page, values: {})
|
|
13
|
+
version = page.versions.live.first
|
|
14
|
+
digest = Operations.digest(values.to_h.stringify_keys)
|
|
15
|
+
Pdf.find_by(version: version, values_digest: digest) || Pdf.create!(version: version, values_digest: digest,
|
|
16
|
+
file: { io: StringIO.new(Printer.pdf(PdfDocument.html(version, values: values))), filename: "#{page.slug}.pdf", content_type: "application/pdf" })
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "keystone_ui/pages"
|
|
6
|
+
|
|
7
|
+
module KeystoneUi
|
|
8
|
+
module Pages
|
|
9
|
+
class PrintFailed < StandardError; end
|
|
10
|
+
|
|
11
|
+
module Printer
|
|
12
|
+
USUAL_PLACES = [
|
|
13
|
+
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
|
14
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def pdf(html)
|
|
20
|
+
Dir.mktmpdir do |folder|
|
|
21
|
+
page = File.join(folder, "page.html")
|
|
22
|
+
printed = File.join(folder, "page.pdf")
|
|
23
|
+
File.write(page, html)
|
|
24
|
+
printed_by_chrome(page, printed)
|
|
25
|
+
File.binread(printed)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def chrome
|
|
30
|
+
Pages.chrome_path.presence || ENV["CHROME_BIN"].presence || USUAL_PLACES.find { |place| File.executable?(place) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def printed_by_chrome(page, printed)
|
|
34
|
+
raise PrintFailed, "No Chrome or Chromium was found to print the PDF. Install one, or name it with KeystoneUi::Pages.chrome_path or CHROME_BIN." if chrome.nil?
|
|
35
|
+
|
|
36
|
+
_output, errors, _status = Open3.capture3(chrome.to_s, "--headless=new", "--disable-gpu", "--use-mock-keychain", "--password-store=basic", "--no-pdf-header-footer", "--print-to-pdf=#{printed}", "file://#{page}")
|
|
37
|
+
raise PrintFailed, "Chrome at #{chrome} could not print the page: #{errors.strip}" unless File.size?(printed)
|
|
38
|
+
rescue SystemCallError => error
|
|
39
|
+
raise PrintFailed, "Chrome at #{chrome} could not be started: #{error.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Pages
|
|
5
|
+
module Values
|
|
6
|
+
class << self
|
|
7
|
+
attr_writer :names
|
|
8
|
+
attr_accessor :provider, :chooser
|
|
9
|
+
|
|
10
|
+
def names
|
|
11
|
+
@names ||= {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def label(name)
|
|
15
|
+
names[name.to_s]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def samples_for(page)
|
|
19
|
+
chooser ? chooser.call(page).to_a : []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sample(page, choice)
|
|
23
|
+
provider ? provider.call(page, choice).to_h.stringify_keys : {}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|