From 64117de5dbd4405490b3d2149cd0122d4abe80e2 Mon Sep 17 00:00:00 2001 From: catmando Date: Fri, 3 Aug 2018 16:49:30 -0400 Subject: [PATCH] closes #102 fixes some more regressions and spec changes related to error handling --- lib/hyper-mesh.rb | 1 + lib/hyper_react/input_tags.rb | 47 ++++ lib/reactive_record/active_record/error.rb | 2 +- .../reactive_record/isomorphic_base.rb | 10 +- spec/batch1/misc/validate_spec.rb | 2 +- spec/batch1/misc/while_loading_spec.rb | 61 +++++ spec/batch4/default_value_spec.rb | 245 ++++++++++++++++++ spec/batch6/inspect_spec.rb | 4 +- 8 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 lib/hyper_react/input_tags.rb create mode 100644 spec/batch4/default_value_spec.rb diff --git a/lib/hyper-mesh.rb b/lib/hyper-mesh.rb index 141d982c..96926a2c 100644 --- a/lib/hyper-mesh.rb +++ b/lib/hyper-mesh.rb @@ -34,6 +34,7 @@ require "reactive_record/active_record/class_methods" require "reactive_record/active_record/instance_methods" require "reactive_record/active_record/base" + require 'hyper_react/input_tags' require_relative 'hypermesh/version' require_relative 'opal/parse_patch' require_relative 'opal/set_patches' diff --git a/lib/hyper_react/input_tags.rb b/lib/hyper_react/input_tags.rb new file mode 100644 index 00000000..606180dd --- /dev/null +++ b/lib/hyper_react/input_tags.rb @@ -0,0 +1,47 @@ +# Special handling of input tags so they ignore defaultValue (and defaultChecked) values while loading. +# This is accomplished by adding a react 'key prop' that tracks whether the default value is loading. +# When the default value transitions from loading to loaded the key will be updated causing react to +# remount the component with the new default value. +# To handle cases where defaultValue (or defaultChecked) is an expression, a proc (or lambda) can be +# provided for the default value. The proc will be called, and if it raises the waiting_on_resources +# flag then we know that within that expression there is a value still being loaded, and the react +# key will be set accordingly. + +module React + module Component + module Tags + %i[INPUT SELECT TEXTAREA].each do |component| + remove_method component + send(:remove_const, component) + tag = component.downcase + klass = Class.new(Hyperloop::Component) do + collect_other_params_as :opts + render do + opts = props.dup # should be opts = params.opts.dup but requires next release candiate of hyper-react + default_value = opts[:defaultValue] || opts[:defaultChecked] + if default_value.respond_to? :call + begin + saved_waiting_on_resources = React::RenderingContext.waiting_on_resources + React::RenderingContext.waiting_on_resources = false + default_value = default_value.call + opts[:key] = React::RenderingContext.waiting_on_resources + if opts[:defaultValue] + opts[:defaultValue] = default_value + else + opts[:defaultChecked] = default_value + end + ensure + React::RenderingContext.waiting_on_resources = !!saved_waiting_on_resources + end + else + opts[:key] = !!default_value.loading? + end + opts[:value] = opts[:value].to_s if opts.key? :value # this may not be needed + React::RenderingContext.render(tag, opts) { children.each(&:render) } + end + end + const_set component, klass + end + end + end +end diff --git a/lib/reactive_record/active_record/error.rb b/lib/reactive_record/active_record/error.rb index 80bf33f3..6fa174de 100644 --- a/lib/reactive_record/active_record/error.rb +++ b/lib/reactive_record/active_record/error.rb @@ -24,7 +24,7 @@ def clear @messages.clear end - def add(attribute, message:) + def add(attribute, message = :invalid, _options = {}) @messages[attribute] << message unless @messages[attribute].include? message end end diff --git a/lib/reactive_record/active_record/reactive_record/isomorphic_base.rb b/lib/reactive_record/active_record/reactive_record/isomorphic_base.rb index e8111289..fd8eb599 100644 --- a/lib/reactive_record/active_record/reactive_record/isomorphic_base.rb +++ b/lib/reactive_record/active_record/reactive_record/isomorphic_base.rb @@ -281,8 +281,8 @@ def send_save_to_server(save, validate, force, &block) Broadcast.to_self backing_records[item[0]].ar_instance, item[2] end else - log("Reactive Record Save Failed: #{response[:message]}", :error) - response[:saved_models].each do | item | + log(response[:message], :error) + response[:saved_models].each do |item| log(" Model: #{item[1]}[#{item[0]}] Attributes: #{item[2]} Errors: #{item[3]}", :error) if item[3] end end @@ -308,7 +308,7 @@ def send_save_to_server(save, validate, force, &block) backing_records.each { |_id, record| record.saved!(true) rescue nil } if save end rescue Exception => e - # debugger + debugger log("Exception raised while saving - #{e}", :error) yield false, e.message, [] if block promise.resolve({success: false, message: e.message, models: []}) @@ -480,7 +480,7 @@ def self.save_records(models, associations, acting_user, validate, save) saved_models = reactive_records.collect do |reactive_record_id, model| messages = model.errors.messages if validate && !model.valid? - all_messages << messages if save && messages + all_messages << [model, messages] if save && messages attributes = model.__hyperloop_secure_attributes(acting_user) [reactive_record_id, model.class.name, attributes, messages] end @@ -494,7 +494,7 @@ def self.save_records(models, associations, acting_user, validate, save) unless all_messages.empty? ::Rails.logger.debug "\033[0;31;1mERROR: HyperModel saving records failed:\033[0;30;21m" - all_messages.each do |message| + all_messages.each do |model, message| ::Rails.logger.debug "\033[0;31;1m\t#{model}: #{message}\033[0;30;21m" end raise 'HyperModel saving records failed!' diff --git a/spec/batch1/misc/validate_spec.rb b/spec/batch1/misc/validate_spec.rb index 7d58a474..5626bdd9 100644 --- a/spec/batch1/misc/validate_spec.rb +++ b/spec/batch1/misc/validate_spec.rb @@ -39,7 +39,7 @@ User.new(last_name: 'f**k').validate.then do |new_user| new_user.errors.messages end - end.to eq("last_name"=>[{"message"=>"no swear words allowed"}]) + end.to eq("last_name"=>["no swear words allowed"]) end it "the valid? method will return true if the model has no errors" do diff --git a/spec/batch1/misc/while_loading_spec.rb b/spec/batch1/misc/while_loading_spec.rb index 22575976..512b610d 100644 --- a/spec/batch1/misc/while_loading_spec.rb +++ b/spec/batch1/misc/while_loading_spec.rb @@ -151,6 +151,67 @@ class WhileLoadingTester < Hyperloop::Component expect(page).not_to have_content('loading...', wait: 0) end + it "achieving while_loading behavior with state variables" do + ReactiveRecord::Operations::Fetch.semaphore.synchronize do + mount "WhileLoadingTester", {}, no_wait: true do + class MyComponent < Hyperloop::Component + render do + SPAN { 'loading...' } + end + end + + class WhileLoadingTester < Hyperloop::Component + + before_mount do + ReactiveRecord.load do + User.find_by_first_name('Lily').last_name + end.then do |last_name| + mutate.last_name last_name + end + end + + render do + if state.last_name + DIV { state.last_name } + else + MyComponent {} + end + end + end + end + expect(page).to have_content('loading...') + expect(page).not_to have_content('DaDog', wait: 0) + end + expect(page).to have_content('DaDog') + expect(page).not_to have_content('loading...', wait: 0) + end + + it "while loading display an application defined element" do + ReactiveRecord::Operations::Fetch.semaphore.synchronize do + mount "WhileLoadingTester", {}, no_wait: true do + class MyComponent < Hyperloop::Component + render do + SPAN { 'loading...' } + end + end + class WhileLoadingTester < Hyperloop::Component + render do + DIV do + User.find_by_first_name('Lily').last_name + end + .while_loading do + MyComponent {} + end + end + end + end + expect(page).to have_content('loading...') + expect(page).not_to have_content('DaDog', wait: 0) + end + expect(page).to have_content('DaDog') + expect(page).not_to have_content('loading...', wait: 0) + end + it "will display the while loading message on condition" do isomorphic do class FetchNow < Hyperloop::ServerOp diff --git a/spec/batch4/default_value_spec.rb b/spec/batch4/default_value_spec.rb new file mode 100644 index 00000000..02ee20ba --- /dev/null +++ b/spec/batch4/default_value_spec.rb @@ -0,0 +1,245 @@ +require 'spec_helper' + +describe 'defaultValue special handling', js: true do + + before(:all) do + ReactiveRecord::Operations::Fetch.class_eval do + def self.semaphore + @semaphore ||= Mutex.new + end + validate { self.class.semaphore.synchronize { true } } + end + require 'pusher' + require 'pusher-fake' + Pusher.app_id = "MY_TEST_ID" + Pusher.key = "MY_TEST_KEY" + Pusher.secret = "MY_TEST_SECRET" + require "pusher-fake/support/base" + + Hyperloop.configuration do |config| + config.transport = :pusher + config.channel_prefix = "synchromesh" + config.opts = {app_id: Pusher.app_id, key: Pusher.key, secret: Pusher.secret}.merge(PusherFake.configuration.web_options) + end + end + + before(:each) do + # spec_helper resets the policy system after each test so we have to setup + # before each test + stub_const 'TestApplication', Class.new + stub_const 'TestApplicationPolicy', Class.new + TestApplicationPolicy.class_eval do + always_allow_connection + regulate_all_broadcasts { |policy| policy.send_all } + allow_change(to: :all, on: [:create, :update, :destroy]) { true } + end + # size_window(:small, :portrait) + FactoryBot.create(:test_model, test_attribute: 'I have been loaded', completed: true) + end + + it 'will not use the defaultValue param until data is loaded - unit test' do + mount 'Tester' do + class LoadableString + def initialize(s) + @s = s + end + def to_s + if loading? + React::RenderingContext.waiting_on_resources = true + "loading..." + else + @s + end + end + def loading? + !React::State.get_state(self, 'loaded?') + end + def value + self + end + def value=(x) + React::State.set_state(self, 'loaded?', Time.now) + @s = x + end + end + class Tester < Hyperloop::Component + include React::IsomorphicHelpers + state :loaded, scope: :shared + def self.loadable_string + @loadable_string + end + before_first_mount do + @loadable_string = LoadableString.new(self) + end + render(DIV) do + INPUT(id: 'uncontrolled-input', defaultValue: Tester.loadable_string.value) + INPUT(id: 'uncontrolled-checkbox', type: :checkbox, defaultChecked: -> () { Tester.loadable_string.to_s == 'I have been loaded' }) + SELECT(id: 'uncontrolled-select', defaultValue: Tester.loadable_string.value) do + OPTION(value: 'loading...') { "loading..." } + OPTION(value: 'I have been loaded') { "I have been loaded" } + OPTION(value: 'another value') { "another value" } + OPTION(value: 'set by user') { "set by user" } + end + TEXTAREA(id: 'uncontrolled-textarea', defaultValue: Tester.loadable_string.value) + + INPUT(id: 'controlled-input', value: Tester.loadable_string.value, valuex: Tester.loadable_string.value) + .on(:change) { |evt| Tester.loadable_string.value = evt.target.value } + INPUT(id: 'controlled-checkbox', type: :checkbox, checked: Tester.loadable_string.to_s == 'I have been loaded') + .on(:change) { |evt| Tester.loadable_string.value = evt.target.checked ? 'I have been loaded' : 'The user clicked off the checkbox' } + SELECT(id: 'controlled-select', value: Tester.loadable_string.value) do + OPTION(value: 'loading...') { "loading..." } + OPTION(value: 'I have been loaded') { "I have been loaded" } + OPTION(value: 'another value') { "another value" } + OPTION(value: 'set by user') { "set by user" } + end + .on(:change) { |evt| Tester.loadable_string.value = evt.target.value } + TEXTAREA(id: 'controlled-textarea', value: Tester.loadable_string.value) + .on(:change) { |evt| Tester.loadable_string.value = evt.target.value } + end + end + end + # initial value which is still loading + expect(find('#uncontrolled-input').value).to eq('loading...') + expect(find('#uncontrolled-checkbox')).not_to be_checked + expect(find('#uncontrolled-select').value).to eq('loading...') + expect(find('#uncontrolled-textarea').value).to eq('loading...') + expect(find('#controlled-input').value).to eq('loading...') + expect(find('#controlled-checkbox')).not_to be_checked + expect(find('#controlled-select').value).to eq('loading...') + expect(find('#controlled-textarea').value).to eq('loading...') + + # now we update so its loaded and the controlled and uncontrolled inputs will change + evaluate_ruby("Tester.loadable_string.value = 'I have been loaded'") + expect(find('#uncontrolled-input').value).to eq('I have been loaded') + expect(find('#uncontrolled-checkbox')).to be_checked + expect(find('#uncontrolled-select').value).to eq('I have been loaded') + expect(find('#uncontrolled-textarea').value).to eq('I have been loaded') + expect(find('#controlled-input').value).to eq('I have been loaded') + expect(find('#controlled-checkbox')).to be_checked + expect(find('#controlled-select').value).to eq('I have been loaded') + expect(find('#controlled-textarea').value).to eq('I have been loaded') + + # now we update it again, but only the controlled inputs should change + evaluate_ruby("Tester.loadable_string.value = 'another value'") + expect(find('#uncontrolled-input').value).to eq('I have been loaded') + expect(find('#uncontrolled-checkbox')).to be_checked + expect(find('#uncontrolled-select').value).to eq('I have been loaded') + expect(find('#uncontrolled-textarea').value).to eq('I have been loaded') + expect(find('#controlled-input').value).to eq('another value') + expect(find('#controlled-checkbox')).not_to be_checked + expect(find('#controlled-select').value).to eq('another value') + expect(find('#controlled-textarea').value).to eq('another value') + + # but if the user changes an input it will always change + find('#uncontrolled-input').set 'I was set by the user' + expect(find('#uncontrolled-input').value).to eq('I was set by the user') + find('#uncontrolled-checkbox').set(false) + expect(find('#uncontrolled-checkbox')).not_to be_checked + find('#uncontrolled-select').find(:option, 'set by user').select_option + expect(find('#uncontrolled-select').value).to eq('set by user') + find('#uncontrolled-textarea').set 'I was set by the user' + expect(find('#uncontrolled-textarea').value).to eq('I was set by the user') + find('#controlled-input').set 'I was set by the user' + expect(find('#controlled-input').value).to eq('I was set by the user') + find('#controlled-checkbox').set(true) + expect(find('#controlled-checkbox')).to be_checked + expect_evaluate_ruby("Tester.loadable_string").to eq('I have been loaded') + find('#controlled-select').find(:option, 'set by user').select_option + expect(find('#controlled-select').value).to eq('set by user') + find('#controlled-textarea').set 'text box set by the user' + expect(find('#controlled-textarea').value).to eq('text box set by the user') + end + + it "will properly update input tags when data is loaded or changed" do + ReactiveRecord::Operations::Fetch.semaphore.synchronize do + mount "InputTester", {}, no_wait: true do + class MyNestedGuy < Hyperloop::Component + render(SPAN) do + "#{User.find_by_first_name('Lily').last_name} is a dog" + end + end + class InputTester < Hyperloop::Component + before_mount do + @test_model = TestModel.first + end + render(DIV) do + INPUT(id: 'uncontrolled-input', defaultValue: @test_model.test_attribute) + INPUT(id: 'uncontrolled-checkbox', type: :checkbox, defaultChecked: @test_model.completed) + SELECT(id: 'uncontrolled-select', defaultValue: @test_model.test_attribute) do + OPTION(value: 'loading...') { "" } + OPTION(value: 'I have been loaded') { "I have been loaded" } + OPTION(value: 'another value') { "another value" } + OPTION(value: 'set by user') { "set by user" } + end + TEXTAREA(id: 'uncontrolled-textarea', defaultValue: @test_model.test_attribute) + + INPUT(id: 'controlled-input', value: @test_model.test_attribute) + .on(:change) { |evt| @test_model.test_attribute = evt.target.value } + INPUT(id: 'controlled-checkbox', type: :checkbox, checked: @test_model.completed) + .on(:change) { |evt| @test_model.completed = evt.target.checked } + SELECT(id: 'controlled-select', value: @test_model.test_attribute) do + OPTION(value: 'loading...') { "" } + OPTION(value: 'I have been loaded') { "I have been loaded" } + OPTION(value: 'another value') { "another value" } + OPTION(value: 'set by user') { "set by user" } + end + .on(:change) { |evt| @test_model.test_attribute = evt.target.value } + TEXTAREA(id: 'controlled-textarea', value: @test_model.test_attribute) + .on(:change) { |evt| @test_model.test_attribute = evt.target.value } + end + end + end + end + expect(page).not_to have_content('loading...', wait: 0) + expect(find('#uncontrolled-input').value).to eq('I have been loaded') + expect(find('#uncontrolled-checkbox')).to be_checked + expect(find('#uncontrolled-select').value).to eq('I have been loaded') + expect(find('#uncontrolled-textarea').value).to eq('I have been loaded') + expect(find('#controlled-input').value).to eq('I have been loaded') + expect(find('#controlled-checkbox')).to be_checked + expect(find('#controlled-select').value).to eq('I have been loaded') + expect(find('#controlled-textarea').value).to eq('I have been loaded') + + TestModel.first.update(test_attribute: 'another value', completed: false) + expect(find('#uncontrolled-input').value).to eq('I have been loaded') + expect(find('#uncontrolled-checkbox')).to be_checked + expect(find('#uncontrolled-select').value).to eq('I have been loaded') + expect(find('#uncontrolled-textarea').value).to eq('I have been loaded') + expect(find('#controlled-input').value).to eq('another value') + expect(find('#controlled-checkbox')).not_to be_checked + expect(find('#controlled-select').value).to eq('another value') + expect(find('#controlled-textarea').value).to eq('another value') + + find('#uncontrolled-input').set 'I was set by the user' + expect(find('#uncontrolled-input').value).to eq('I was set by the user') + + find('#uncontrolled-checkbox').set(false) + expect(find('#uncontrolled-checkbox')).not_to be_checked + + find('#uncontrolled-select').find(:option, 'set by user').select_option + expect(find('#uncontrolled-select').value).to eq('set by user') + + find('#uncontrolled-textarea').set 'I was set by the user' + expect(find('#uncontrolled-textarea').value).to eq('I was set by the user') + + find('#controlled-input').set 'I was also set by the user' + expect(find('#controlled-input').value).to eq('I was also set by the user') + evaluate_promise('TestModel.first.save') + expect(TestModel.first.test_attribute).to eq('I was also set by the user') + + find('#controlled-checkbox').set(true) + expect(find('#controlled-checkbox')).to be_checked + evaluate_promise('TestModel.first.save') + expect(TestModel.first.completed).to be_truthy + + find('#controlled-select').find(:option, 'set by user').select_option + expect(find('#controlled-select').value).to eq('set by user') + evaluate_promise('TestModel.first.save') + expect(TestModel.first.test_attribute).to eq('set by user') + + find('#controlled-textarea').set 'text box set by the user' + expect(find('#controlled-textarea').value).to eq('text box set by the user') + evaluate_promise('TestModel.first.save') + expect(TestModel.first.test_attribute).to eq('text box set by the user') + end +end diff --git a/spec/batch6/inspect_spec.rb b/spec/batch6/inspect_spec.rb index 75062419..1dfc78e9 100644 --- a/spec/batch6/inspect_spec.rb +++ b/spec/batch6/inspect_spec.rb @@ -96,7 +96,7 @@ todo.save.then do todo.inspect end - end.to match /\[{\"message\"=>\"can't be blank\"}\]}\] >/ + end.to match /\[\"can't be blank\"\]}\] >/ end it 'updated records with the errors after attempting to save' do @@ -108,7 +108,7 @@ end.then do todo.inspect end - end.to match /\[{\"message\"=>\"can't be blank\"}\]}\] >/ + end.to match /\[\"can't be blank\"\]}\] >/ end it 'new records with the errors after attempting to save (deprecated error handler)' do