funicular 0.1.0 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +12 -4
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +25 -18
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +115 -87
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6908 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +3 -0
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/form_for_test.rb +106 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +246 -47
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +28 -6
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +78 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/vdom.rb +2 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +48 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
data/lib/funicular/version.rb
CHANGED
data/lib/funicular.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require_relative "funicular/version"
|
|
4
4
|
require_relative "funicular/configuration"
|
|
5
5
|
require_relative "funicular/compiler"
|
|
6
|
+
require_relative "funicular/plugin"
|
|
7
|
+
require_relative "funicular/schema"
|
|
8
|
+
require_relative "funicular/ssr"
|
|
6
9
|
|
|
7
10
|
module Funicular
|
|
8
11
|
class Error < StandardError; end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Funicular
|
|
7
|
+
module Generators
|
|
8
|
+
class ChatGenerator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dirname)
|
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def migration_version
|
|
18
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_rails_files
|
|
22
|
+
migration_template "create_funicular_chat_messages.rb.tt",
|
|
23
|
+
"db/migrate/create_funicular_chat_messages.rb"
|
|
24
|
+
template "funicular_chat_message.rb.tt",
|
|
25
|
+
"app/models/funicular_chat_message.rb"
|
|
26
|
+
template "funicular_chat_controller.rb.tt",
|
|
27
|
+
"app/controllers/funicular_chat_controller.rb"
|
|
28
|
+
template "funicular_chat_messages_controller.rb.tt",
|
|
29
|
+
"app/controllers/funicular_chat_messages_controller.rb"
|
|
30
|
+
template "application_cable_connection.rb.tt",
|
|
31
|
+
"app/channels/application_cable/connection.rb" unless File.exist?(Rails.root.join("app", "channels", "application_cable", "connection.rb"))
|
|
32
|
+
template "application_cable_channel.rb.tt",
|
|
33
|
+
"app/channels/application_cable/channel.rb" unless File.exist?(Rails.root.join("app", "channels", "application_cable", "channel.rb"))
|
|
34
|
+
template "funicular_chat_channel.rb.tt",
|
|
35
|
+
"app/channels/funicular_chat_channel.rb"
|
|
36
|
+
template "show.html.erb.tt",
|
|
37
|
+
"app/views/funicular_chat/show.html.erb"
|
|
38
|
+
template "funicular_chat.css.tt",
|
|
39
|
+
"app/assets/stylesheets/funicular_chat.css"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create_funicular_files
|
|
43
|
+
template "funicular_chat_component.rb.tt",
|
|
44
|
+
"app/funicular/components/funicular_chat_component.rb"
|
|
45
|
+
template "funicular_chat_component_picotest.rb.tt",
|
|
46
|
+
"test/funicular/client/funicular_chat_component_picotest.rb"
|
|
47
|
+
|
|
48
|
+
@initializer_existed = File.exist?(Rails.root.join("app", "funicular", "initializer.rb"))
|
|
49
|
+
if @initializer_existed
|
|
50
|
+
say_status :skip, "app/funicular/initializer.rb already exists", :yellow
|
|
51
|
+
else
|
|
52
|
+
template "initializer.rb.tt", "app/funicular/initializer.rb"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_routes
|
|
57
|
+
routes_file = Rails.root.join("config", "routes.rb")
|
|
58
|
+
if File.exist?(routes_file) && File.read(routes_file).include?('get "funicular_chat"')
|
|
59
|
+
say_status :skip, "funicular_chat routes already exist", :yellow
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
route <<~ROUTES
|
|
64
|
+
get "funicular_chat", to: "funicular_chat#show"
|
|
65
|
+
get "funicular_chat/messages", to: "funicular_chat_messages#index"
|
|
66
|
+
post "funicular_chat/messages", to: "funicular_chat_messages#create"
|
|
67
|
+
ROUTES
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_picoruby_include_tag
|
|
71
|
+
layout = Rails.root.join("app", "views", "layouts", "application.html.erb")
|
|
72
|
+
return unless File.exist?(layout)
|
|
73
|
+
|
|
74
|
+
content = File.read(layout)
|
|
75
|
+
return if content.include?("picoruby_include_tag")
|
|
76
|
+
|
|
77
|
+
if content.include?("<%= csrf_meta_tags %>")
|
|
78
|
+
inject_into_file layout.to_s,
|
|
79
|
+
" <%= picoruby_include_tag %>\n",
|
|
80
|
+
after: " <%= csrf_meta_tags %>\n"
|
|
81
|
+
else
|
|
82
|
+
say_status :skip, "layout does not contain csrf_meta_tags; add <%= picoruby_include_tag %> manually", :yellow
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def print_next_steps
|
|
87
|
+
say ""
|
|
88
|
+
say "Funicular chat generated.", :green
|
|
89
|
+
say ""
|
|
90
|
+
say "Next steps:"
|
|
91
|
+
say " 1. Run `bin/rails db:migrate`"
|
|
92
|
+
say " 2. Run `bin/rails funicular:compile`"
|
|
93
|
+
say " 3. Open `/funicular_chat` in two browser windows"
|
|
94
|
+
say ""
|
|
95
|
+
|
|
96
|
+
if @initializer_existed
|
|
97
|
+
say "Add this route inside your existing Funicular.start block:"
|
|
98
|
+
say " router.get('/funicular_chat', to: FunicularChatComponent, as: 'funicular_chat')"
|
|
99
|
+
say ""
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
.funicular-chat {
|
|
2
|
+
min-height: 100vh;
|
|
3
|
+
background: #f3f4f6;
|
|
4
|
+
padding: 1.5rem;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.funicular-chat__panel {
|
|
8
|
+
max-width: 48rem;
|
|
9
|
+
margin: 0 auto;
|
|
10
|
+
background: #fff;
|
|
11
|
+
border: 1px solid #e5e7eb;
|
|
12
|
+
border-radius: 0.5rem;
|
|
13
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.funicular-chat__header {
|
|
18
|
+
padding: 1rem 1.25rem;
|
|
19
|
+
border-bottom: 1px solid #e5e7eb;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.funicular-chat__title {
|
|
23
|
+
margin: 0;
|
|
24
|
+
color: #111827;
|
|
25
|
+
font-size: 1.25rem;
|
|
26
|
+
font-weight: 700;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.funicular-chat__subtitle {
|
|
30
|
+
margin: 0.25rem 0 0;
|
|
31
|
+
color: #6b7280;
|
|
32
|
+
font-size: 0.875rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.funicular-chat__messages {
|
|
36
|
+
height: 24rem;
|
|
37
|
+
overflow-y: auto;
|
|
38
|
+
padding: 1.25rem;
|
|
39
|
+
background: #f9fafb;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.funicular-chat__empty {
|
|
43
|
+
color: #6b7280;
|
|
44
|
+
font-size: 0.875rem;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.funicular-chat__message {
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: flex-start;
|
|
50
|
+
gap: 0.75rem;
|
|
51
|
+
padding: 0.75rem;
|
|
52
|
+
margin-bottom: 0.75rem;
|
|
53
|
+
background: #fff;
|
|
54
|
+
border: 1px solid #e5e7eb;
|
|
55
|
+
border-radius: 0.375rem;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.funicular-chat__message-name {
|
|
59
|
+
flex: 0 0 auto;
|
|
60
|
+
min-width: 7rem;
|
|
61
|
+
color: #1f2937;
|
|
62
|
+
font-size: 0.875rem;
|
|
63
|
+
font-weight: 700;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.funicular-chat__message-body {
|
|
67
|
+
min-width: 0;
|
|
68
|
+
color: #374151;
|
|
69
|
+
font-size: 0.875rem;
|
|
70
|
+
overflow-wrap: anywhere;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.funicular-chat__form {
|
|
74
|
+
padding: 1.25rem;
|
|
75
|
+
border-top: 1px solid #e5e7eb;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.funicular-chat__form-row {
|
|
79
|
+
display: flex;
|
|
80
|
+
gap: 0.75rem;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.funicular-chat__input {
|
|
84
|
+
width: 100%;
|
|
85
|
+
min-width: 0;
|
|
86
|
+
padding: 0.5rem 0.75rem;
|
|
87
|
+
border: 1px solid #d1d5db;
|
|
88
|
+
border-radius: 0.375rem;
|
|
89
|
+
font: inherit;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.funicular-chat__input:focus {
|
|
93
|
+
border-color: #2563eb;
|
|
94
|
+
outline: 2px solid rgba(37, 99, 235, 0.25);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.funicular-chat__button {
|
|
98
|
+
flex: 0 0 auto;
|
|
99
|
+
padding: 0.5rem 1rem;
|
|
100
|
+
border: 0;
|
|
101
|
+
border-radius: 0.375rem;
|
|
102
|
+
font: inherit;
|
|
103
|
+
font-weight: 700;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.funicular-chat__button--enabled {
|
|
107
|
+
color: #fff;
|
|
108
|
+
background: #2563eb;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.funicular-chat__button--enabled:hover {
|
|
113
|
+
background: #1d4ed8;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.funicular-chat__button--disabled {
|
|
117
|
+
color: #fff;
|
|
118
|
+
background: #2563eb;
|
|
119
|
+
cursor: not-allowed;
|
|
120
|
+
opacity: 0.5;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.funicular-chat__error {
|
|
124
|
+
margin-bottom: 0.75rem;
|
|
125
|
+
color: #b91c1c;
|
|
126
|
+
font-size: 0.875rem;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@media (max-width: 640px) {
|
|
130
|
+
.funicular-chat {
|
|
131
|
+
padding: 0.75rem;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.funicular-chat__form-row {
|
|
135
|
+
flex-direction: column;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.funicular-chat__message-name {
|
|
139
|
+
min-width: 5rem;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
class FunicularChatComponent < Funicular::Component
|
|
2
|
+
styles do
|
|
3
|
+
shell "funicular-chat"
|
|
4
|
+
panel "funicular-chat__panel"
|
|
5
|
+
header "funicular-chat__header"
|
|
6
|
+
title "funicular-chat__title"
|
|
7
|
+
subtitle "funicular-chat__subtitle"
|
|
8
|
+
messages "funicular-chat__messages"
|
|
9
|
+
empty "funicular-chat__empty"
|
|
10
|
+
message "funicular-chat__message"
|
|
11
|
+
name "funicular-chat__message-name"
|
|
12
|
+
body "funicular-chat__message-body"
|
|
13
|
+
form "funicular-chat__form"
|
|
14
|
+
row "funicular-chat__form-row"
|
|
15
|
+
input "funicular-chat__input"
|
|
16
|
+
button base: "funicular-chat__button",
|
|
17
|
+
variants: {
|
|
18
|
+
enabled: "funicular-chat__button--enabled",
|
|
19
|
+
disabled: "funicular-chat__button--disabled"
|
|
20
|
+
}
|
|
21
|
+
error "funicular-chat__error"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize_state
|
|
25
|
+
{
|
|
26
|
+
name: "Rails developer",
|
|
27
|
+
body: "",
|
|
28
|
+
messages: [],
|
|
29
|
+
error: nil,
|
|
30
|
+
sending: false,
|
|
31
|
+
connected: false
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def component_mounted
|
|
36
|
+
load_messages
|
|
37
|
+
subscribe
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def component_will_unmount
|
|
41
|
+
@subscription.unsubscribe if @subscription
|
|
42
|
+
@consumer.cleanup if @consumer
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def load_messages
|
|
46
|
+
Funicular::HTTP.get("/funicular_chat/messages") do |response|
|
|
47
|
+
if response.ok
|
|
48
|
+
patch(messages: response.data)
|
|
49
|
+
else
|
|
50
|
+
patch(error: response.error_message || "Failed to load messages")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def subscribe
|
|
56
|
+
@consumer = Funicular::Cable.create_consumer("/cable")
|
|
57
|
+
@subscription = @consumer.subscriptions.create(channel: "FunicularChatChannel") do |message|
|
|
58
|
+
patch(messages: state.messages + [message])
|
|
59
|
+
end
|
|
60
|
+
@subscription.on_connected do
|
|
61
|
+
patch(connected: true)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def update_name(event)
|
|
66
|
+
patch(name: event.target[:value].to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def update_body(event)
|
|
70
|
+
patch(body: event.target[:value].to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def send_message(event = nil)
|
|
74
|
+
event.preventDefault if event
|
|
75
|
+
return if state.sending
|
|
76
|
+
|
|
77
|
+
name = state.name.to_s.strip
|
|
78
|
+
body = state.body.to_s.strip
|
|
79
|
+
if name == "" || body == ""
|
|
80
|
+
patch(error: "Name and message are required")
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
patch(sending: true, error: nil)
|
|
85
|
+
Funicular::HTTP.post(
|
|
86
|
+
"/funicular_chat/messages",
|
|
87
|
+
{ message: { name: name, body: body } }
|
|
88
|
+
) do |response|
|
|
89
|
+
if response.ok
|
|
90
|
+
patch(body: "", sending: false)
|
|
91
|
+
else
|
|
92
|
+
patch(error: response.error_message || "Failed to send message", sending: false)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render
|
|
98
|
+
div(class: s.shell) do
|
|
99
|
+
div(class: s.panel) do
|
|
100
|
+
div(class: s.header) do
|
|
101
|
+
h1(class: s.title) { "Funicular Chat" }
|
|
102
|
+
p(class: s.subtitle) do
|
|
103
|
+
state.connected ? "Realtime Ruby chat over ActionCable" : "Connecting..."
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
div(class: s.messages) do
|
|
108
|
+
if state.messages.empty?
|
|
109
|
+
p(class: s.empty) { "No messages yet." }
|
|
110
|
+
else
|
|
111
|
+
state.messages.each do |message|
|
|
112
|
+
div(class: s.message, key: message["id"]) do
|
|
113
|
+
div(class: s.name) { message["name"] }
|
|
114
|
+
div(class: s.body) { message["body"] }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
form(onsubmit: :send_message, class: s.form) do
|
|
121
|
+
if state.error
|
|
122
|
+
div(class: s.error) { state.error }
|
|
123
|
+
end
|
|
124
|
+
div(class: s.row) do
|
|
125
|
+
input(type: "text", value: state.name, placeholder: "Name", oninput: :update_name, class: s.input)
|
|
126
|
+
input(type: "text", value: state.body, placeholder: "Message", oninput: :update_body, class: s.input)
|
|
127
|
+
button(type: "submit", class: s.button(state.sending ? :disabled : :enabled), disabled: state.sending) do
|
|
128
|
+
state.sending ? "Sending..." : "Send"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
class FunicularChatComponentTest < Funicular::Testing::DOMTest
|
|
2
|
+
def setup
|
|
3
|
+
super
|
|
4
|
+
Funicular::HTTP.__test_messages = [
|
|
5
|
+
{ "id" => 1, "name" => "Alice", "body" => "Hello from Rails" }
|
|
6
|
+
]
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def test_renders_loaded_messages
|
|
10
|
+
mount FunicularChatComponent
|
|
11
|
+
drain
|
|
12
|
+
|
|
13
|
+
assert_text "Funicular Chat"
|
|
14
|
+
assert_text "Hello from Rails"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module Funicular
|
|
19
|
+
module HTTP
|
|
20
|
+
class << self
|
|
21
|
+
attr_accessor :__test_messages
|
|
22
|
+
|
|
23
|
+
def get(url, &block)
|
|
24
|
+
block.call(Response.new(200, __test_messages))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def post(url, body = nil, &block)
|
|
28
|
+
block.call(Response.new(201, { "id" => 2, "name" => body[:message][:name], "body" => body[:message][:body] }))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module Cable
|
|
34
|
+
class TestConsumer
|
|
35
|
+
attr_reader :subscriptions
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@subscriptions = TestSubscriptions.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cleanup
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class TestSubscriptions
|
|
46
|
+
def create(params, &block)
|
|
47
|
+
TestSubscription.new
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class TestSubscription
|
|
52
|
+
def on_connected(&block)
|
|
53
|
+
block.call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def unsubscribe
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.create_consumer(url)
|
|
61
|
+
TestConsumer.new
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class FunicularChatMessage < ApplicationRecord
|
|
2
|
+
validates :name, presence: true, length: { maximum: 40 }
|
|
3
|
+
validates :body, presence: true, length: { maximum: 500 }
|
|
4
|
+
|
|
5
|
+
def as_json(*)
|
|
6
|
+
{
|
|
7
|
+
id: id,
|
|
8
|
+
name: name,
|
|
9
|
+
body: body,
|
|
10
|
+
created_at: created_at.iso8601
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class FunicularChatMessagesController < ApplicationController
|
|
2
|
+
def index
|
|
3
|
+
messages = FunicularChatMessage.order(:created_at).last(50)
|
|
4
|
+
render json: messages
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def create
|
|
8
|
+
message = FunicularChatMessage.new(message_params)
|
|
9
|
+
|
|
10
|
+
if message.save
|
|
11
|
+
ActionCable.server.broadcast("funicular_chat", message.as_json)
|
|
12
|
+
render json: message, status: :created
|
|
13
|
+
else
|
|
14
|
+
render json: { errors: message.errors.full_messages }, status: :unprocessable_entity
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def message_params
|
|
21
|
+
params.require(:message).permit(:name, :body)
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/tasks/funicular.rake
CHANGED
|
@@ -4,6 +4,7 @@ namespace :funicular do
|
|
|
4
4
|
desc "Compile Funicular Ruby files to .mrb format"
|
|
5
5
|
task compile: :environment do
|
|
6
6
|
require "funicular/compiler"
|
|
7
|
+
require "funicular/plugin"
|
|
7
8
|
|
|
8
9
|
source_dir = Rails.root.join("app", "funicular")
|
|
9
10
|
output_file = Rails.root.join("app", "assets", "builds", "app.mrb")
|
|
@@ -15,13 +16,20 @@ namespace :funicular do
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
begin
|
|
19
|
+
plugin_registry = Funicular::Plugin::Registry.new(Rails.root)
|
|
20
|
+
plugin_registry.validate!
|
|
21
|
+
plugin_registry.sync_assets
|
|
18
22
|
compiler = Funicular::Compiler.new(
|
|
19
23
|
source_dir: source_dir,
|
|
20
24
|
output_file: output_file,
|
|
21
|
-
debug_mode: debug_mode
|
|
25
|
+
debug_mode: debug_mode,
|
|
26
|
+
prepend_source_files: plugin_registry.local_source_files
|
|
22
27
|
)
|
|
23
28
|
compiler.compile
|
|
24
|
-
rescue Funicular::
|
|
29
|
+
rescue Funicular::Plugin::Error => e
|
|
30
|
+
puts "ERROR: #{e.message}"
|
|
31
|
+
exit 1
|
|
32
|
+
rescue Funicular::Compiler::PicorbcMissingError => e
|
|
25
33
|
puts "ERROR: #{e.message}"
|
|
26
34
|
exit 1
|
|
27
35
|
rescue => e
|
|
@@ -46,8 +54,8 @@ namespace :funicular do
|
|
|
46
54
|
end
|
|
47
55
|
end
|
|
48
56
|
|
|
49
|
-
desc "Install Funicular debug assets
|
|
50
|
-
task install: ["install:debug_assets", "install:wasm"] do
|
|
57
|
+
desc "Install Funicular debug assets, PicoRuby.wasm artifacts, and test support into a Rails app"
|
|
58
|
+
task install: ["install:debug_assets", "install:wasm", "install:test"] do
|
|
51
59
|
puts ""
|
|
52
60
|
puts "All Funicular assets installed."
|
|
53
61
|
puts ""
|
|
@@ -64,6 +72,10 @@ namespace :funicular do
|
|
|
64
72
|
puts ' <%= javascript_include_tag "funicular_debug", "data-turbo-track": "reload" %>'
|
|
65
73
|
puts ' <%= stylesheet_link_tag "funicular_debug", "data-turbo-track": "reload" %>'
|
|
66
74
|
puts ' <% end %>'
|
|
75
|
+
puts ""
|
|
76
|
+
puts " 4. Run `npm install` if package.json was created or updated."
|
|
77
|
+
puts " Client-side Funicular tests live under test/funicular/client/**/*_picotest.rb"
|
|
78
|
+
puts " and run through `bin/rails test`."
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
namespace :install do
|
|
@@ -126,6 +138,77 @@ namespace :funicular do
|
|
|
126
138
|
puts "Installed PicoRuby #{variant} build to #{dst}"
|
|
127
139
|
end
|
|
128
140
|
end
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
desc "Install Funicular client test support"
|
|
144
|
+
task :test do
|
|
145
|
+
require "fileutils"
|
|
146
|
+
require "json"
|
|
147
|
+
|
|
148
|
+
test_dir = Rails.root.join("test")
|
|
149
|
+
funicular_test_dir = test_dir.join("funicular")
|
|
150
|
+
client_test_dir = funicular_test_dir.join("client")
|
|
151
|
+
FileUtils.mkdir_p(client_test_dir)
|
|
152
|
+
|
|
153
|
+
test_helper = test_dir.join("test_helper.rb")
|
|
154
|
+
unless File.exist?(test_helper)
|
|
155
|
+
File.write(test_helper, <<~TEST_HELPER)
|
|
156
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
157
|
+
|
|
158
|
+
require_relative "../config/environment"
|
|
159
|
+
require "rails/test_help"
|
|
160
|
+
TEST_HELPER
|
|
161
|
+
puts "Installed #{test_helper}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
application_test = funicular_test_dir.join("application_test.rb")
|
|
165
|
+
unless File.exist?(application_test)
|
|
166
|
+
File.write(application_test, <<~APPLICATION_TEST)
|
|
167
|
+
require_relative "../test_helper"
|
|
168
|
+
require "funicular/testing"
|
|
169
|
+
|
|
170
|
+
class FunicularApplicationTest < ActiveSupport::TestCase
|
|
171
|
+
test "client-side Funicular tests" do
|
|
172
|
+
result = Funicular::Testing.run!(timeout_ms: 10_000)
|
|
173
|
+
Funicular::Testing.assert_picotests(self, result)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
APPLICATION_TEST
|
|
177
|
+
puts "Installed #{application_test}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
keep_file = client_test_dir.join(".keep")
|
|
181
|
+
FileUtils.touch(keep_file) unless File.exist?(keep_file)
|
|
182
|
+
|
|
183
|
+
package_json = Rails.root.join("package.json")
|
|
184
|
+
package = if File.exist?(package_json)
|
|
185
|
+
JSON.parse(File.read(package_json))
|
|
186
|
+
else
|
|
187
|
+
{ "private" => true }
|
|
188
|
+
end
|
|
189
|
+
package["devDependencies"] ||= {}
|
|
190
|
+
package["devDependencies"]["jsdom"] ||= "^26.1.0"
|
|
191
|
+
File.write(package_json, JSON.pretty_generate(package) + "\n")
|
|
192
|
+
puts "Updated #{package_json}"
|
|
193
|
+
|
|
194
|
+
gitignore = Rails.root.join(".gitignore")
|
|
195
|
+
if File.exist?(gitignore)
|
|
196
|
+
content = File.read(gitignore)
|
|
197
|
+
unless content.lines.any? { |line| line.chomp == "/node_modules" }
|
|
198
|
+
File.open(gitignore, "a") do |f|
|
|
199
|
+
f.puts
|
|
200
|
+
f.puts "# Ignore Node dependencies."
|
|
201
|
+
f.puts "/node_modules"
|
|
202
|
+
end
|
|
203
|
+
puts "Updated #{gitignore}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
puts "Installed Funicular test support:"
|
|
208
|
+
puts " - #{application_test}"
|
|
209
|
+
puts " - #{client_test_dir}"
|
|
210
|
+
puts " - jsdom dev dependency in #{package_json}"
|
|
211
|
+
end
|
|
129
212
|
end
|
|
130
213
|
end
|
|
131
214
|
|