From a42ea77673317d17dc21cca40918335c0840c302 Mon Sep 17 00:00:00 2001 From: Brian Knoles Date: Thu, 14 Nov 2024 17:14:47 -0500 Subject: [PATCH 1/9] Support dot notation for :only keys in partial reloads --- lib/inertia_rails/renderer.rb | 62 ++++++++++++++++--- .../inertia_render_test_controller.rb | 24 +++++++ spec/dummy/config/routes.rb | 1 + spec/inertia/rendering_spec.rb | 26 ++++++++ 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/lib/inertia_rails/renderer.rb b/lib/inertia_rails/renderer.rb index 348347f5..21fc6be6 100644 --- a/lib/inertia_rails/renderer.rb +++ b/lib/inertia_rails/renderer.rb @@ -74,18 +74,12 @@ def merge_props(shared_data, props) end def computed_props - _props = merge_props(shared_data, props).select do |key, prop| - if rendering_partial_component? - partial_keys.none? || key.in?(partial_keys) || prop.is_a?(AlwaysProp) - else - !prop.is_a?(LazyProp) - end - end + _props = merge_props(shared_data, props) - drop_partial_except_keys(_props) if rendering_partial_component? + deep_transform_props _props do |prop, path| + next [:dont_keep] unless keep_prop?(prop, path) - deep_transform_values _props do |prop| - case prop + transformed_prop = case prop when BaseProp prop.call(controller) when Proc @@ -93,6 +87,8 @@ def computed_props else prop end + + [:keep, transformed_prop] end end @@ -105,6 +101,22 @@ def page } end + def deep_transform_props(props, parent_path = '', &block) + props.reduce({}) do |transformed_props, (key, prop)| + current_path = [parent_path, key].reject(&:empty?).join('.') + + if prop.is_a?(Hash) + nested = deep_transform_props(prop, current_path, &block) + transformed_props.merge!(key => nested) unless nested.empty? + else + action, transformed_prop = block.call(prop, current_path) + transformed_props.merge!(key => transformed_prop) if action == :keep + end + + transformed_props + end + end + def deep_transform_values(hash, &block) return block.call(hash) unless hash.is_a? Hash @@ -138,5 +150,35 @@ def resolve_component(component) configuration.component_path_resolver(path: controller.controller_path, action: controller.action_name) end + + def keep_prop?(prop, path) + return true if prop.is_a?(AlwaysProp) + + if rendering_partial_component? + path_with_prefixes = path_prefixes(path) + return false if excluded_by_only_partial_keys?(path_with_prefixes) + return false if excluded_by_except_partial_keys?(path_with_prefixes) + end + + # Precedence: Evaluate LazyProp only after partial keys have been checked + return false if prop.is_a?(LazyProp) && !rendering_partial_component? + + true + end + + def path_prefixes(path) + parts = path.split('.') + (0...parts.length).map do |i| + parts[0..i].join('.') + end + end + + def excluded_by_only_partial_keys?(path_with_prefixes) + partial_keys.present? && (path_with_prefixes & partial_keys.map(&:to_s)).empty? + end + + def excluded_by_except_partial_keys?(path_with_prefixes) + partial_except_keys.present? && (path_with_prefixes & partial_except_keys.map(&:to_s)).any? + end end end diff --git a/spec/dummy/app/controllers/inertia_render_test_controller.rb b/spec/dummy/app/controllers/inertia_render_test_controller.rb index 5ab23d49..7d18ed90 100644 --- a/spec/dummy/app/controllers/inertia_render_test_controller.rb +++ b/spec/dummy/app/controllers/inertia_render_test_controller.rb @@ -24,6 +24,30 @@ def except_props } end + def deeply_nested_props + render inertia: 'TestComponent', props: { + flat: 'flat param', + lazy: InertiaRails.lazy('lazy param'), + nested_lazy: InertiaRails.lazy do + { + first: 'first nested lazy param', + } + end, + nested: { + first: 'first nested param', + second: 'second nested param', + deeply_nested: { + first: 'first deeply nested param', + second: false, + what_about_nil: nil, + deeply_nested_always: InertiaRails.always { 'deeply nested always prop' }, + deeply_nested_lazy: InertiaRails.lazy { 'deeply nested lazy prop' } + } + }, + always: InertiaRails.always { 'always prop' } + } + end + def view_data render inertia: 'TestComponent', view_data: { name: 'Brian', diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 44da7017..67f708c2 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -33,6 +33,7 @@ get 'always_props' => 'inertia_render_test#always_props' get 'except_props' => 'inertia_render_test#except_props' get 'non_inertiafied' => 'inertia_test#non_inertiafied' + get 'deeply_nested_props' => 'inertia_render_test#deeply_nested_props' get 'instance_props_test' => 'inertia_rails_mimic#instance_props_test' get 'default_render_test' => 'inertia_rails_mimic#default_render_test' diff --git a/spec/inertia/rendering_spec.rb b/spec/inertia/rendering_spec.rb index 9c49e562..f5cb1713 100644 --- a/spec/inertia/rendering_spec.rb +++ b/spec/inertia/rendering_spec.rb @@ -111,6 +111,32 @@ is_expected.to include('Brandon') end end + + context 'with dot notation' do + let(:headers) do + { + 'X-Inertia' => true, + 'X-Inertia-Partial-Data' => 'nested.first,nested.deeply_nested.second,nested.deeply_nested.what_about_nil', + 'X-Inertia-Partial-Component' => 'TestComponent', + } + end + + before { get deeply_nested_props_path, headers: headers } + + it 'only renders the dot notated props' do + expect(response.parsed_body['props']).to eq( + 'always' => 'always prop', + 'nested' => { + 'first' => 'first nested param', + 'deeply_nested' => { + 'second' => false, + 'what_about_nil' => nil, + 'deeply_nested_always' => 'deeply nested always prop', + }, + }, + ) + end + end end context 'partial except rendering' do From c5653d2347a0fdc7f43c4fcf91b93c08136fd4ea Mon Sep 17 00:00:00 2001 From: Brian Knoles Date: Thu, 14 Nov 2024 17:17:54 -0500 Subject: [PATCH 2/9] Remove no-longer-necessary code --- lib/inertia_rails/renderer.rb | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lib/inertia_rails/renderer.rb b/lib/inertia_rails/renderer.rb index 21fc6be6..6fade07d 100644 --- a/lib/inertia_rails/renderer.rb +++ b/lib/inertia_rails/renderer.rb @@ -117,22 +117,6 @@ def deep_transform_props(props, parent_path = '', &block) end end - def deep_transform_values(hash, &block) - return block.call(hash) unless hash.is_a? Hash - - hash.transform_values {|value| deep_transform_values(value, &block)} - end - - def drop_partial_except_keys(hash) - partial_except_keys.each do |key| - parts = key.to_s.split('.').map(&:to_sym) - *initial_keys, last_key = parts - current = initial_keys.any? ? hash.dig(*initial_keys) : hash - - current.delete(last_key) if current.is_a?(Hash) && !current[last_key].is_a?(AlwaysProp) - end - end - def partial_keys (@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact.map(&:to_sym) end From af8fb911bfb0aa2ca5fb3224873cc87867f2ebc5 Mon Sep 17 00:00:00 2001 From: Brian Knoles Date: Wed, 20 Nov 2024 22:39:22 -0500 Subject: [PATCH 3/9] Squeeze out a few more unncessary iterations --- lib/inertia_rails/renderer.rb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/inertia_rails/renderer.rb b/lib/inertia_rails/renderer.rb index 6fade07d..78f23c66 100644 --- a/lib/inertia_rails/renderer.rb +++ b/lib/inertia_rails/renderer.rb @@ -101,9 +101,9 @@ def page } end - def deep_transform_props(props, parent_path = '', &block) + def deep_transform_props(props, parent_path = [], &block) props.reduce({}) do |transformed_props, (key, prop)| - current_path = [parent_path, key].reject(&:empty?).join('.') + current_path = parent_path + [key] if prop.is_a?(Hash) nested = deep_transform_props(prop, current_path, &block) @@ -118,11 +118,11 @@ def deep_transform_props(props, parent_path = '', &block) end def partial_keys - (@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact.map(&:to_sym) + (@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact end def partial_except_keys - (@request.headers['X-Inertia-Partial-Except'] || '').split(',').filter_map(&:to_sym) + (@request.headers['X-Inertia-Partial-Except'] || '').split(',').compact end def rendering_partial_component? @@ -150,19 +150,18 @@ def keep_prop?(prop, path) true end - def path_prefixes(path) - parts = path.split('.') + def path_prefixes(parts) (0...parts.length).map do |i| parts[0..i].join('.') end end def excluded_by_only_partial_keys?(path_with_prefixes) - partial_keys.present? && (path_with_prefixes & partial_keys.map(&:to_s)).empty? + partial_keys.present? && (path_with_prefixes & partial_keys).empty? end def excluded_by_except_partial_keys?(path_with_prefixes) - partial_except_keys.present? && (path_with_prefixes & partial_except_keys.map(&:to_s)).any? + partial_except_keys.present? && (path_with_prefixes & partial_except_keys).any? end end end From 776da3ac0f3158e6ea69882af2be12594ccb8381 Mon Sep 17 00:00:00 2001 From: Brian Knoles Date: Wed, 20 Nov 2024 22:50:41 -0500 Subject: [PATCH 4/9] Don't filter out intentionally empty hashes from transformed props --- lib/inertia_rails/renderer.rb | 2 +- spec/dummy/app/controllers/inertia_render_test_controller.rb | 1 + spec/inertia/rendering_spec.rb | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/inertia_rails/renderer.rb b/lib/inertia_rails/renderer.rb index 78f23c66..ee767aab 100644 --- a/lib/inertia_rails/renderer.rb +++ b/lib/inertia_rails/renderer.rb @@ -105,7 +105,7 @@ def deep_transform_props(props, parent_path = [], &block) props.reduce({}) do |transformed_props, (key, prop)| current_path = parent_path + [key] - if prop.is_a?(Hash) + if prop.is_a?(Hash) && prop.any? nested = deep_transform_props(prop, current_path, &block) transformed_props.merge!(key => nested) unless nested.empty? else diff --git a/spec/dummy/app/controllers/inertia_render_test_controller.rb b/spec/dummy/app/controllers/inertia_render_test_controller.rb index 7d18ed90..629870c9 100644 --- a/spec/dummy/app/controllers/inertia_render_test_controller.rb +++ b/spec/dummy/app/controllers/inertia_render_test_controller.rb @@ -40,6 +40,7 @@ def deeply_nested_props first: 'first deeply nested param', second: false, what_about_nil: nil, + what_about_empty_hash: {}, deeply_nested_always: InertiaRails.always { 'deeply nested always prop' }, deeply_nested_lazy: InertiaRails.lazy { 'deeply nested lazy prop' } } diff --git a/spec/inertia/rendering_spec.rb b/spec/inertia/rendering_spec.rb index f5cb1713..baf3f4d3 100644 --- a/spec/inertia/rendering_spec.rb +++ b/spec/inertia/rendering_spec.rb @@ -116,7 +116,7 @@ let(:headers) do { 'X-Inertia' => true, - 'X-Inertia-Partial-Data' => 'nested.first,nested.deeply_nested.second,nested.deeply_nested.what_about_nil', + 'X-Inertia-Partial-Data' => 'nested.first,nested.deeply_nested.second,nested.deeply_nested.what_about_nil,nested.deeply_nested.what_about_empty_hash', 'X-Inertia-Partial-Component' => 'TestComponent', } end @@ -131,6 +131,7 @@ 'deeply_nested' => { 'second' => false, 'what_about_nil' => nil, + 'what_about_empty_hash' => {}, 'deeply_nested_always' => 'deeply nested always prop', }, }, From a62b21a540b34372d2bc77a75e398ff38a638709 Mon Sep 17 00:00:00 2001 From: Brian Knoles Date: Wed, 20 Nov 2024 23:07:35 -0500 Subject: [PATCH 5/9] Add a couple more specs to satisfy curiosity --- spec/inertia/rendering_spec.rb | 53 ++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/spec/inertia/rendering_spec.rb b/spec/inertia/rendering_spec.rb index baf3f4d3..d7006387 100644 --- a/spec/inertia/rendering_spec.rb +++ b/spec/inertia/rendering_spec.rb @@ -138,6 +138,59 @@ ) end end + + context 'with both partial and except dot notation' do + let(:headers) do + { + 'X-Inertia' => true, + 'X-Inertia-Partial-Component' => 'TestComponent', + 'X-Inertia-Partial-Data' => 'lazy,nested.deeply_nested', + 'X-Inertia-Partial-Except' => 'nested.deeply_nested.first', + } + end + + before { get deeply_nested_props_path, headers: headers } + + it 'renders the partial data and excludes the excepted data' do + expect(response.parsed_body['props']).to eq( + 'always' => 'always prop', + 'lazy' => 'lazy param', + 'nested' => { + 'deeply_nested' => { + 'second' => false, + 'what_about_nil' => nil, + 'what_about_empty_hash' => {}, + 'deeply_nested_always' => 'deeply nested always prop', + 'deeply_nested_lazy' => 'deeply nested lazy prop', + }, + }, + ) + end + end + + context 'with nonsensical partial data that includes and excludes the same prop and tries to exclude an always prop' do + let(:headers) do + { + 'X-Inertia' => true, + 'X-Inertia-Partial-Component' => 'TestComponent', + 'X-Inertia-Partial-Data' => 'lazy', + 'X-Inertia-Partial-Except' => 'lazy,always', + } + end + + before { get deeply_nested_props_path, headers: headers } + + it 'excludes everything but Always props' do + expect(response.parsed_body['props']).to eq( + 'always' => 'always prop', + 'nested' => { + 'deeply_nested' => { + 'deeply_nested_always' => 'deeply nested always prop', + }, + }, + ) + end + end end context 'partial except rendering' do From c6df93d9c68f0d09658a03912f2b05b7639a7cb8 Mon Sep 17 00:00:00 2001 From: Brian Knoles Date: Thu, 21 Nov 2024 00:08:02 -0500 Subject: [PATCH 6/9] Add specs to document what happens if you try to only/except evaluated prop keys --- .../inertia_render_test_controller.rb | 6 ++ spec/inertia/rendering_spec.rb | 60 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/spec/dummy/app/controllers/inertia_render_test_controller.rb b/spec/dummy/app/controllers/inertia_render_test_controller.rb index 629870c9..1257f8dc 100644 --- a/spec/dummy/app/controllers/inertia_render_test_controller.rb +++ b/spec/dummy/app/controllers/inertia_render_test_controller.rb @@ -36,6 +36,12 @@ def deeply_nested_props nested: { first: 'first nested param', second: 'second nested param', + evaluated: -> do + { + first: 'first evaluated nested param', + second: 'second evaluated nested param' + } + end, deeply_nested: { first: 'first deeply nested param', second: false, diff --git a/spec/inertia/rendering_spec.rb b/spec/inertia/rendering_spec.rb index d7006387..af527147 100644 --- a/spec/inertia/rendering_spec.rb +++ b/spec/inertia/rendering_spec.rb @@ -191,6 +191,66 @@ ) end end + + context 'with only props that target transformed data' do + let(:headers) do + { + 'X-Inertia' => true, + 'X-Inertia-Partial-Component' => 'TestComponent', + 'X-Inertia-Partial-Data' => 'nested.evaluated.first', + } + end + + before { get deeply_nested_props_path, headers: headers } + + it 'filters out the entire evaluated prop' do + expect(response.parsed_body['props']).to eq( + 'always' => 'always prop', + 'nested' => { + 'deeply_nested' => { + 'deeply_nested_always' => 'deeply nested always prop', + }, + }, + ) + end + end + + context 'with except props that target transformed data' do + let(:headers) do + { + 'X-Inertia' => true, + 'X-Inertia-Partial-Component' => 'TestComponent', + 'X-Inertia-Partial-Except' => 'nested.evaluated.first', + } + end + + before { get deeply_nested_props_path, headers: headers } + + it 'renders the entire evaluated prop' do + expect(response.parsed_body['props']).to eq( + 'always' => 'always prop', + 'flat' => 'flat param', + 'lazy' => 'lazy param', + 'nested_lazy' => { 'first' => 'first nested lazy param' }, + 'nested' => { + 'first' => 'first nested param', + 'second' => 'second nested param', + 'evaluated' => { + 'first' => 'first evaluated nested param', + 'second' => 'second evaluated nested param', + }, + 'deeply_nested' => { + 'first' => 'first deeply nested param', + 'second' => false, + 'what_about_nil' => nil, + 'what_about_empty_hash' => {}, + 'deeply_nested_always' => 'deeply nested always prop', + 'deeply_nested_lazy' => 'deeply nested lazy prop', + }, + }, + ) + end + end end context 'partial except rendering' do From 0c566eee7620c568a16e8093cc8d55815261f2da Mon Sep 17 00:00:00 2001 From: Brian Knoles Date: Thu, 21 Nov 2024 00:14:22 -0500 Subject: [PATCH 7/9] Document dot notation in partial reloads --- docs/guide/partial-reloads.md | 50 ++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/guide/partial-reloads.md b/docs/guide/partial-reloads.md index 526e93c7..424f76d5 100644 --- a/docs/guide/partial-reloads.md +++ b/docs/guide/partial-reloads.md @@ -46,6 +46,8 @@ router.visit(url, { ## Except certain props +In addition to the only visit option you can also use the except option to specify which data the server should exclude. This option should also be an array of keys which correspond to the keys of the props. + :::tabs key:frameworks == Vue @@ -79,7 +81,53 @@ router.visit(url, { ::: -In addition to the only visit option you can also use the except option to specify which data the server should exclude. This option should also be an array of keys which correspond to the keys of the props. +## Dot notation + +Both the `only` and `except` visit options support dot notation to specify nested data, and they can be used together. In the following example, only `settings.theme` will be rendered, but without its `colors` property. + +:::tabs key:frameworks +== Vue + +```js +import { router } from '@inertiajs/vue3' + +router.visit(url, { + only: ['settings.theme'], + except: ['setting.theme.colors'], +}) +``` + +== React + +```jsx +import { router } from '@inertiajs/react' + +router.visit(url, { + only: ['settings.theme'], + except: ['setting.theme.colors'], +}) +``` + +== Svelte 4|Svelte 5 + +```js +import { router } from '@inertiajs/svelte' + +router.visit(url, { + only: ['settings.theme'], + except: ['setting.theme.colors'], +}) +``` + +Please remember that, by design, partial reloading filters props _before_ they are evaluated, so it can only target explicitly defined prop keys. Let's say you have this prop: + +`users: -> { User.all }` + +Requesting `only: ['users.name']` will exclude the entire `users` prop, since `users.name` is not available before evaluating the prop. + +Requesting `except: ['users.name']` will not exclude anything. + +::: ## Router shorthand From c4606ef864d0a6350dbb5a89eb65144dc6ef4534 Mon Sep 17 00:00:00 2001 From: Brian Knoles Date: Thu, 21 Nov 2024 00:20:38 -0500 Subject: [PATCH 8/9] Fix closing tabs tag --- docs/guide/partial-reloads.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/partial-reloads.md b/docs/guide/partial-reloads.md index 424f76d5..42332bae 100644 --- a/docs/guide/partial-reloads.md +++ b/docs/guide/partial-reloads.md @@ -119,6 +119,8 @@ router.visit(url, { }) ``` +::: + Please remember that, by design, partial reloading filters props _before_ they are evaluated, so it can only target explicitly defined prop keys. Let's say you have this prop: `users: -> { User.all }` @@ -127,8 +129,6 @@ Requesting `only: ['users.name']` will exclude the entire `users` prop, since `u Requesting `except: ['users.name']` will not exclude anything. -::: - ## Router shorthand Since partial reloads can only be made to the same page component the user is already on, it almost always makes sense to just use the `router.reload()` method, which automatically uses the current URL. From 2027984d56e13fd3ffa5a5cebad73e9f13f6d99b Mon Sep 17 00:00:00 2001 From: Brian Knoles Date: Thu, 21 Nov 2024 10:39:29 -0500 Subject: [PATCH 9/9] Use constants for the enum-like transformation actions --- lib/inertia_rails/renderer.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/inertia_rails/renderer.rb b/lib/inertia_rails/renderer.rb index ee767aab..4e19b2e2 100644 --- a/lib/inertia_rails/renderer.rb +++ b/lib/inertia_rails/renderer.rb @@ -6,6 +6,9 @@ module InertiaRails class Renderer + KEEP_PROP = :keep + DONT_KEEP_PROP = :dont_keep + attr_reader( :component, :configuration, @@ -77,7 +80,7 @@ def computed_props _props = merge_props(shared_data, props) deep_transform_props _props do |prop, path| - next [:dont_keep] unless keep_prop?(prop, path) + next [DONT_KEEP_PROP] unless keep_prop?(prop, path) transformed_prop = case prop when BaseProp @@ -88,7 +91,7 @@ def computed_props prop end - [:keep, transformed_prop] + [KEEP_PROP, transformed_prop] end end @@ -110,7 +113,7 @@ def deep_transform_props(props, parent_path = [], &block) transformed_props.merge!(key => nested) unless nested.empty? else action, transformed_prop = block.call(prop, current_path) - transformed_props.merge!(key => transformed_prop) if action == :keep + transformed_props.merge!(key => transformed_prop) if action == KEEP_PROP end transformed_props