Skip to content

Commit

Permalink
[LiveComponent] Allow binding LiveProp to URL query parameter
Browse files Browse the repository at this point in the history
  • Loading branch information
squrious committed Oct 25, 2023
1 parent 262db2b commit e25e877
Show file tree
Hide file tree
Showing 25 changed files with 727 additions and 12 deletions.
1 change: 1 addition & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2.13.0

- Add support for URL binding in `LiveProp`
- Add deferred rendering of Live Components

## 2.12.0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Component from '../index';
import { PluginInterface } from './PluginInterface';
export default class implements PluginInterface {
private element;
private mapping;
attachToComponent(component: Component): void;
private registerBindings;
private updateUrl;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Component from '../index';
import { PluginInterface } from './PluginInterface';
export default class implements PluginInterface {
private element;
private mapping;
attachToComponent(component: Component): void;
private registerBindings;
private updateUrl;
}
87 changes: 87 additions & 0 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2696,6 +2696,92 @@ class ComponentRegistry {
}
}

class AdvancedURLSearchParams extends URLSearchParams {
set(name, value) {
if (typeof value !== 'object') {
super.set(name, value);
}
else {
this.delete(name);
if (Array.isArray(value)) {
value.forEach((v) => {
this.append(`${name}[]`, v);
});
}
else {
Object.entries(value).forEach(([index, v]) => {
this.append(`${name}[${index}]`, v);
});
}
}
}
delete(name) {
super.delete(name);
const pattern = new RegExp(`^${name}(\\[.*\\])?$`);
for (const key of this.keys()) {
if (key.match(pattern)) {
this.delete(key);
}
}
}
}
function setQueryParam(param, value) {
const queryParams = new AdvancedURLSearchParams(window.location.search);
queryParams.set(param, value);
const url = urlFromQueryParams(queryParams);
history.replaceState(history.state, '', url);
}
function removeQueryParam(param) {
const queryParams = new AdvancedURLSearchParams(window.location.search);
queryParams.delete(param);
const url = urlFromQueryParams(queryParams);
history.replaceState(history.state, '', url);
}
function urlFromQueryParams(queryParams) {
let queryString = '';
if (Array.from(queryParams.entries()).length > 0) {
queryString += '?' + queryParams.toString();
}
return window.location.origin + window.location.pathname + queryString + window.location.hash;
}

class QueryStringPlugin {
constructor() {
this.mapping = new Map;
}
attachToComponent(component) {
this.element = component.element;
this.registerBindings();
component.on('connect', (component) => {
this.updateUrl(component);
});
component.on('render:finished', (component) => {
this.updateUrl(component);
});
}
registerBindings() {
const rawQueryMapping = this.element.dataset.liveQueryMapping;
if (rawQueryMapping === undefined) {
return;
}
const mapping = JSON.parse(rawQueryMapping);
Object.entries(mapping).forEach(([key, config]) => {
this.mapping.set(key, config);
});
}
updateUrl(component) {
this.mapping.forEach((mapping, propName) => {
const value = component.valueStore.get(propName);
if (value === '' || value === null || value === undefined) {
removeQueryParam(mapping.name);
}
else {
setQueryParam(mapping.name, value);
}
});
}
}

const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element);
class LiveControllerDefault extends Controller {
constructor() {
Expand Down Expand Up @@ -2723,6 +2809,7 @@ class LiveControllerDefault extends Controller {
new PageUnloadingPlugin(),
new PollingPlugin(),
new SetValueOntoModelFieldsPlugin(),
new QueryStringPlugin(),
];
plugins.forEach((plugin) => {
this.component.addPlugin(plugin);
Expand Down
2 changes: 2 additions & 0 deletions src/LiveComponent/assets/dist/url_utils.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export declare function setQueryParam(param: string, value: any): void;
export declare function removeQueryParam(param: string): void;
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Component from '../index';
import { PluginInterface } from './PluginInterface';
import {
setQueryParam, removeQueryParam,
} from '../../url_utils';

type QueryMapping = {
name: string,
}

export default class implements PluginInterface {
private element: Element;
private mapping: Map<string,QueryMapping> = new Map;

attachToComponent(component: Component): void {
this.element = component.element;
this.registerBindings();

component.on('connect', (component: Component) => {
this.updateUrl(component);
});

component.on('render:finished', (component: Component)=> {
this.updateUrl(component);
});
}

private registerBindings(): void {
const rawQueryMapping = (this.element as HTMLElement).dataset.liveQueryMapping;
if (rawQueryMapping === undefined) {
return;
}

const mapping = JSON.parse(rawQueryMapping) as {[p: string]: QueryMapping};

Object.entries(mapping).forEach(([key, config]) => {
this.mapping.set(key, config);
})
}

private updateUrl(component: Component){
this.mapping.forEach((mapping, propName) => {
const value = component.valueStore.get(propName);
if (value === '' || value === null || value === undefined) {
removeQueryParam(mapping.name);
} else {
setQueryParam(mapping.name, value);
}

});
}
}
2 changes: 2 additions & 0 deletions src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModel
import { PluginInterface } from './Component/plugins/PluginInterface';
import getModelBinding from './Directive/get_model_binding';
import ComponentRegistry from './ComponentRegistry';
import QueryStringPlugin from './Component/plugins/QueryStringPlugin';

export { Component };
export const getComponent = (element: HTMLElement): Promise<Component> =>
Expand Down Expand Up @@ -102,6 +103,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
new PageUnloadingPlugin(),
new PollingPlugin(),
new SetValueOntoModelFieldsPlugin(),
new QueryStringPlugin(),
];
plugins.forEach((plugin) => {
this.component.addPlugin(plugin);
Expand Down
57 changes: 57 additions & 0 deletions src/LiveComponent/assets/src/url_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
class AdvancedURLSearchParams extends URLSearchParams {
set(name: string, value: any) {
if (typeof value !== 'object') {
super.set(name, value);
} else {
this.delete(name);
if (Array.isArray(value)) {
value.forEach((v) => {
this.append(`${name}[]`, v);
});
} else {
Object.entries(value).forEach(([index, v]) => {
this.append(`${name}[${index}]`, v as string);
});
}
}
}

delete(name: string) {
super.delete(name);
const pattern = new RegExp(`^${name}(\\[.*\\])?$`);
for (const key of this.keys()) {
if (key.match(pattern)) {
this.delete(key);
}
}
}
}

export function setQueryParam(param: string, value: any) {
const queryParams = new AdvancedURLSearchParams(window.location.search);

queryParams.set(param, value);

const url = urlFromQueryParams(queryParams);

history.replaceState(history.state, '', url);
}

export function removeQueryParam(param: string) {
const queryParams = new AdvancedURLSearchParams(window.location.search);

queryParams.delete(param);

const url = urlFromQueryParams(queryParams);

history.replaceState(history.state, '', url);
}

function urlFromQueryParams(queryParams: URLSearchParams) {
let queryString = '';
if (Array.from(queryParams.entries()).length > 0) {
queryString += '?' + queryParams.toString();
}

return window.location.origin + window.location.pathname + queryString + window.location.hash;
}
16 changes: 16 additions & 0 deletions src/LiveComponent/src/Attribute/LiveProp.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ final class LiveProp
*/
private null|string|array $onUpdated;

/**
* @var bool
*
* Tells if this property should be bound to the URL
*/
private bool $url;

/**
* @param bool|array $writable If true, this property can be changed by the frontend.
* Or set to an array of paths within this object/array
Expand All @@ -73,6 +80,8 @@ final class LiveProp
* from the value used when originally rendering
* this child, the value in the child will be updated
* to match the new value and the child will be re-rendered
* @param bool $url if true, this property will be synchronized with a query parameter
* in the URL
*/
public function __construct(
bool|array $writable = false,
Expand All @@ -84,6 +93,7 @@ public function __construct(
string $format = null,
bool $updateFromParent = false,
string|array $onUpdated = null,
bool $url = false,
) {
$this->writable = $writable;
$this->hydrateWith = $hydrateWith;
Expand All @@ -94,6 +104,7 @@ public function __construct(
$this->format = $format;
$this->acceptUpdatesFromParent = $updateFromParent;
$this->onUpdated = $onUpdated;
$this->url = $url;

if ($this->useSerializerForHydration && ($this->hydrateWith || $this->dehydrateWith)) {
throw new \InvalidArgumentException('Cannot use useSerializerForHydration with hydrateWith or dehydrateWith.');
Expand Down Expand Up @@ -188,4 +199,9 @@ public function onUpdated(): null|string|array
{
return $this->onUpdated;
}

public function url(): bool
{
return $this->url;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Symfony\UX\LiveComponent\EventListener\DeferLiveComponentSubscriber;
use Symfony\UX\LiveComponent\EventListener\InterceptChildComponentRenderSubscriber;
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
use Symfony\UX\LiveComponent\EventListener\QueryStringInitializeSubscriber;
use Symfony\UX\LiveComponent\EventListener\ResetDeterministicIdSubscriber;
use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType;
use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface;
Expand All @@ -44,6 +45,7 @@
use Symfony\UX\LiveComponent\Util\FingerprintCalculator;
use Symfony\UX\LiveComponent\Util\LiveComponentStack;
use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor;
use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentRenderer;
Expand Down Expand Up @@ -216,6 +218,16 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
->addTag('container.service_subscriber', ['key' => LiveControllerAttributesCreator::class, 'id' => 'ux.live_component.live_controller_attributes_creator'])
;

$container->register('ux.live_component.query_string_props_extractor', QueryStringPropsExtractor::class);

$container->register('ux.live_component.query_string_initializer_subscriber', QueryStringInitializeSubscriber::class)
->setArguments([
new Reference('request_stack'),
new Reference('ux.live_component.metadata_factory'),
new Reference('ux.live_component.query_string_props_extractor'),
])
->addTag('kernel.event_subscriber');

$container->register('ux.live_component.defer_live_component_subscriber', DeferLiveComponentSubscriber::class)
->setArguments([
new Reference('ux.twig_component.component_stack'),
Expand Down

0 comments on commit e25e877

Please sign in to comment.