Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support dot notation for :only keys in partial reloads #163

Merged
merged 9 commits into from
Nov 21, 2024
50 changes: 49 additions & 1 deletion docs/guide/partial-reloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
71 changes: 48 additions & 23 deletions lib/inertia_rails/renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,25 +74,21 @@ 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)
Copy link
Collaborator Author

@bknoles bknoles Nov 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why return this array from the block now? Well, the block has two jobs 1) to filter out props we don't need and 2) transform the keepers into their final format. We want track those separately so that the transformation can return things that are nil/false-y. (i think the typical pattern here would be next nil unless some_condition?)

(This block will also be a nice place to rewrite the logic from #154 )

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: could we move these symbol values to constants since they're acting as an enum?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure! as an aside, :dont_keep doesn't do anything at all... it's just there to reveal intention


deep_transform_values _props do |prop|
case prop
transformed_prop = case prop
when BaseProp
prop.call(controller)
when Proc
controller.instance_exec(&prop)
else
prop
end

[:keep, transformed_prop]
end
end

Expand All @@ -105,28 +101,28 @@ def page
}
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 deep_transform_props(props, parent_path = [], &block)
props.reduce({}) do |transformed_props, (key, prop)|
current_path = parent_path + [key]

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
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?
Expand All @@ -138,5 +134,34 @@ def resolve_component(component)

configuration.component_path_resolver(path: controller.controller_path, action: controller.action_name)
end

def keep_prop?(prop, path)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all the prop filtering logic now lives in one concise place. i debated pulling this out into a class, but I don't think the cleanliness is worth the extra indirection.

return true if prop.is_a?(AlwaysProp)

if rendering_partial_component?
path_with_prefixes = path_prefixes(path)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth nothing that if we're in a partial reload, we'll end up calculating this array for every single prop key. That should still be way faster than fully evaluating all of those props, though.

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
31 changes: 31 additions & 0 deletions spec/dummy/app/controllers/inertia_render_test_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions spec/dummy/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
140 changes: 140 additions & 0 deletions spec/inertia/rendering_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down