diff --git a/docs/guide/partial-reloads.md b/docs/guide/partial-reloads.md index 526e93c7..42332bae 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 diff --git a/lib/inertia_rails/renderer.rb b/lib/inertia_rails/renderer.rb index 348347f5..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, @@ -74,18 +77,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_PROP] 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 +90,8 @@ def computed_props else prop end + + [KEEP_PROP, transformed_prop] end end @@ -105,28 +104,28 @@ def page } end - def deep_transform_values(hash, &block) - return block.call(hash) unless hash.is_a? Hash + def deep_transform_props(props, parent_path = [], &block) + props.reduce({}) do |transformed_props, (key, prop)| + current_path = parent_path + [key] - 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 + if prop.is_a?(Hash) && prop.any? + 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_PROP + end - current.delete(last_key) if current.is_a?(Hash) && !current[last_key].is_a?(AlwaysProp) + transformed_props end 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? @@ -138,5 +137,34 @@ 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(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).empty? + end + + def excluded_by_except_partial_keys?(path_with_prefixes) + partial_except_keys.present? && (path_with_prefixes & partial_except_keys).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..1257f8dc 100644 --- a/spec/dummy/app/controllers/inertia_render_test_controller.rb +++ b/spec/dummy/app/controllers/inertia_render_test_controller.rb @@ -24,6 +24,37 @@ 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', + evaluated: -> do + { + first: 'first evaluated nested param', + second: 'second evaluated nested param' + } + end, + deeply_nested: { + 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' } + } + }, + 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..af527147 100644 --- a/spec/inertia/rendering_spec.rb +++ b/spec/inertia/rendering_spec.rb @@ -111,6 +111,146 @@ 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,nested.deeply_nested.what_about_empty_hash', + '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, + 'what_about_empty_hash' => {}, + 'deeply_nested_always' => 'deeply nested always prop', + }, + }, + ) + 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 + + 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