Skip to content

Commit 0d14638

Browse files
committed
feature #621 [Live] Adding ComponentRegistry as an easy way to fetch a Live Component (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Live] Adding ComponentRegistry as an easy way to fetch a Live Component | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | Fix #612 | License | MIT Hi! My solution for #612. It seems... to work perfectly well and it's dead-simple: ```js const element = // find some Element that is your live controller rot ComponentRegistry.get(element).then((component) => { // use the Component component.render() }); // or use await const component = await ComponentRegistry.get(element)l component.render(); ``` Cheers! Commits ------- 748b800 [Live] Adding ComponentRegistry as an easy way to fetch a Live Component
2 parents 70f9959 + 748b800 commit 0d14638

File tree

8 files changed

+167
-38
lines changed

8 files changed

+167
-38
lines changed

src/Autocomplete/assets/dist/controller.js

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ function __classPrivateFieldGet(receiver, state, kind, f) {
2222
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
2323
}
2424

25-
var _instances, _getCommonConfig, _createAutocomplete, _createAutocompleteWithHtmlContents, _createAutocompleteWithRemoteData, _stripTags, _mergeObjects, _createTomSelect, _dispatchEvent;
25+
var _default_1_instances, _default_1_getCommonConfig, _default_1_createAutocomplete, _default_1_createAutocompleteWithHtmlContents, _default_1_createAutocompleteWithRemoteData, _default_1_stripTags, _default_1_mergeObjects, _default_1_createTomSelect, _default_1_dispatchEvent;
2626
class default_1 extends Controller {
2727
constructor() {
2828
super(...arguments);
29-
_instances.add(this);
29+
_default_1_instances.add(this);
3030
}
3131
initialize() {
3232
this.element.setAttribute('data-live-ignore', '');
@@ -39,14 +39,14 @@ class default_1 extends Controller {
3939
}
4040
connect() {
4141
if (this.urlValue) {
42-
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocompleteWithRemoteData).call(this, this.urlValue, this.minCharactersValue);
42+
this.tomSelect = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createAutocompleteWithRemoteData).call(this, this.urlValue, this.minCharactersValue);
4343
return;
4444
}
4545
if (this.optionsAsHtmlValue) {
46-
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocompleteWithHtmlContents).call(this);
46+
this.tomSelect = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createAutocompleteWithHtmlContents).call(this);
4747
return;
4848
}
49-
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocomplete).call(this);
49+
this.tomSelect = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createAutocomplete).call(this);
5050
}
5151
disconnect() {
5252
this.tomSelect.revertSettings.innerHTML = this.element.innerHTML;
@@ -77,7 +77,7 @@ class default_1 extends Controller {
7777
return this.preloadValue;
7878
}
7979
}
80-
_instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() {
80+
_default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() {
8181
const plugins = {};
8282
const isMultiple = !this.selectElement || this.selectElement.multiple;
8383
if (!this.formElement.disabled && !isMultiple) {
@@ -109,19 +109,19 @@ _instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() {
109109
if (!this.selectElement && !this.urlValue) {
110110
config.shouldLoad = () => false;
111111
}
112-
return __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, config, this.tomSelectOptionsValue);
113-
}, _createAutocomplete = function _createAutocomplete() {
114-
const config = __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, __classPrivateFieldGet(this, _instances, "m", _getCommonConfig).call(this), {
112+
return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, config, this.tomSelectOptionsValue);
113+
}, _default_1_createAutocomplete = function _default_1_createAutocomplete() {
114+
const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), {
115115
maxOptions: this.selectElement ? this.selectElement.options.length : 50,
116116
});
117-
return __classPrivateFieldGet(this, _instances, "m", _createTomSelect).call(this, config);
118-
}, _createAutocompleteWithHtmlContents = function _createAutocompleteWithHtmlContents() {
119-
const config = __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, __classPrivateFieldGet(this, _instances, "m", _getCommonConfig).call(this), {
117+
return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
118+
}, _default_1_createAutocompleteWithHtmlContents = function _default_1_createAutocompleteWithHtmlContents() {
119+
const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), {
120120
maxOptions: this.selectElement ? this.selectElement.options.length : 50,
121121
score: (search) => {
122122
const scoringFunction = this.tomSelect.getScoreFunction(search);
123123
return (item) => {
124-
return scoringFunction(Object.assign(Object.assign({}, item), { text: __classPrivateFieldGet(this, _instances, "m", _stripTags).call(this, item.text) }));
124+
return scoringFunction(Object.assign(Object.assign({}, item), { text: __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_stripTags).call(this, item.text) }));
125125
};
126126
},
127127
render: {
@@ -133,9 +133,9 @@ _instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() {
133133
},
134134
},
135135
});
136-
return __classPrivateFieldGet(this, _instances, "m", _createTomSelect).call(this, config);
137-
}, _createAutocompleteWithRemoteData = function _createAutocompleteWithRemoteData(autocompleteEndpointUrl, minCharacterLength) {
138-
const config = __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, __classPrivateFieldGet(this, _instances, "m", _getCommonConfig).call(this), {
136+
return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
137+
}, _default_1_createAutocompleteWithRemoteData = function _default_1_createAutocompleteWithRemoteData(autocompleteEndpointUrl, minCharacterLength) {
138+
const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), {
139139
firstUrl: (query) => {
140140
const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
141141
return `${autocompleteEndpointUrl}${separator}query=${encodeURIComponent(query)}`;
@@ -174,17 +174,17 @@ _instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() {
174174
},
175175
preload: this.preload,
176176
});
177-
return __classPrivateFieldGet(this, _instances, "m", _createTomSelect).call(this, config);
178-
}, _stripTags = function _stripTags(string) {
177+
return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
178+
}, _default_1_stripTags = function _default_1_stripTags(string) {
179179
return string.replace(/(<([^>]+)>)/gi, '');
180-
}, _mergeObjects = function _mergeObjects(object1, object2) {
180+
}, _default_1_mergeObjects = function _default_1_mergeObjects(object1, object2) {
181181
return Object.assign(Object.assign({}, object1), object2);
182-
}, _createTomSelect = function _createTomSelect(options) {
183-
__classPrivateFieldGet(this, _instances, "m", _dispatchEvent).call(this, 'autocomplete:pre-connect', { options });
182+
}, _default_1_createTomSelect = function _default_1_createTomSelect(options) {
183+
__classPrivateFieldGet(this, _default_1_instances, "m", _default_1_dispatchEvent).call(this, 'autocomplete:pre-connect', { options });
184184
const tomSelect = new TomSelect(this.formElement, options);
185-
__classPrivateFieldGet(this, _instances, "m", _dispatchEvent).call(this, 'autocomplete:connect', { tomSelect, options });
185+
__classPrivateFieldGet(this, _default_1_instances, "m", _default_1_dispatchEvent).call(this, 'autocomplete:connect', { tomSelect, options });
186186
return tomSelect;
187-
}, _dispatchEvent = function _dispatchEvent(name, payload) {
187+
}, _default_1_dispatchEvent = function _default_1_dispatchEvent(name, payload) {
188188
this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true }));
189189
};
190190
default_1.values = {

src/LiveComponent/assets/dist/live_controller.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Controller } from '@hotwired/stimulus';
22
import Component from './Component';
33
export { Component };
4+
export declare const getComponent: (element: HTMLElement) => Promise<Component>;
45
export interface LiveEvent extends CustomEvent {
56
detail: {
67
controller: LiveController;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1563,6 +1563,9 @@ class Component {
15631563
let newElement;
15641564
try {
15651565
newElement = htmlToElement(html);
1566+
if (!newElement.matches('[data-controller~=live]')) {
1567+
throw new Error('A live component template must contain a single root controller element.');
1568+
}
15661569
}
15671570
catch (error) {
15681571
console.error('There was a problem with the component HTML returned:');
@@ -2168,6 +2171,37 @@ function getModelBinding (modelDirective) {
21682171
};
21692172
}
21702173

2174+
const ComponentRegistry = class {
2175+
constructor() {
2176+
this.components = new WeakMap();
2177+
}
2178+
registerComponent(element, definition) {
2179+
this.components.set(element, definition);
2180+
}
2181+
unregisterComponent(element) {
2182+
this.components.delete(element);
2183+
}
2184+
getComponent(element) {
2185+
return new Promise((resolve, reject) => {
2186+
let count = 0;
2187+
const maxCount = 10;
2188+
const interval = setInterval(() => {
2189+
const component = this.components.get(element);
2190+
if (component) {
2191+
resolve(component);
2192+
}
2193+
count++;
2194+
if (count > maxCount) {
2195+
clearInterval(interval);
2196+
reject(new Error(`Component not found for element ${getElementAsTagText(element)}`));
2197+
}
2198+
}, 5);
2199+
});
2200+
}
2201+
};
2202+
var ComponentRegistry$1 = new ComponentRegistry();
2203+
2204+
const getComponent = (element) => ComponentRegistry$1.getComponent(element);
21712205
class default_1 extends Controller {
21722206
constructor() {
21732207
super(...arguments);
@@ -2203,13 +2237,15 @@ class default_1 extends Controller {
22032237
this.elementEventListeners.forEach(({ event, callback }) => {
22042238
this.component.element.addEventListener(event, callback);
22052239
});
2240+
ComponentRegistry$1.registerComponent(this.element, this.component);
22062241
this._dispatchEvent('live:connect');
22072242
}
22082243
disconnect() {
22092244
this.component.disconnect();
22102245
this.elementEventListeners.forEach(({ event, callback }) => {
22112246
this.component.element.removeEventListener(event, callback);
22122247
});
2248+
ComponentRegistry$1.unregisterComponent(this.element);
22132249
this._dispatchEvent('live:disconnect');
22142250
}
22152251
update(event) {
@@ -2349,4 +2385,4 @@ default_1.values = {
23492385
fingerprint: String,
23502386
};
23512387

2352-
export { Component, default_1 as default };
2388+
export { Component, default_1 as default, getComponent };
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import Component from './Component';
2+
import { getElementAsTagText } from './dom_utils';
3+
4+
const ComponentRegistry = class {
5+
private components = new WeakMap<HTMLElement, Component>();
6+
7+
public registerComponent(element: HTMLElement, definition: Component) {
8+
this.components.set(element, definition);
9+
}
10+
11+
public unregisterComponent(element: HTMLElement) {
12+
this.components.delete(element);
13+
}
14+
15+
public getComponent(element: HTMLElement): Promise<Component> {
16+
return new Promise((resolve, reject) => {
17+
let count = 0;
18+
const maxCount = 10;
19+
const interval = setInterval(() => {
20+
const component = this.components.get(element);
21+
if (component) {
22+
resolve(component);
23+
}
24+
count++;
25+
26+
if (count > maxCount) {
27+
clearInterval(interval);
28+
reject(new Error(`Component not found for element ${getElementAsTagText(element)}`));
29+
}
30+
}, 5);
31+
});
32+
}
33+
};
34+
35+
export default new ComponentRegistry();

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import PollingPlugin from './Component/plugins/PollingPlugin';
1717
import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModelFieldsPlugin';
1818
import { PluginInterface } from './Component/plugins/PluginInterface';
1919
import getModelBinding from './Directive/get_model_binding';
20+
import ComponentRegistry from './ComponentRegistry';
2021

2122
export { Component };
23+
export const getComponent = (element: HTMLElement): Promise<Component> => ComponentRegistry.getComponent(element);
2224

2325
export interface LiveEvent extends CustomEvent {
2426
detail: {
@@ -104,6 +106,7 @@ export default class extends Controller<HTMLElement> implements LiveController {
104106
this.component.element.addEventListener(event, callback);
105107
});
106108

109+
ComponentRegistry.registerComponent(this.element, this.component);
107110
this._dispatchEvent('live:connect');
108111
}
109112

@@ -114,6 +117,7 @@ export default class extends Controller<HTMLElement> implements LiveController {
114117
this.component.element.removeEventListener(event, callback);
115118
});
116119

120+
ComponentRegistry.unregisterComponent(this.element);
117121
this._dispatchEvent('live:disconnect');
118122
}
119123

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import Component from '../src/Component';
2+
import ComponentRegistry from '../src/ComponentRegistry';
3+
import BackendRequest from '../src/BackendRequest';
4+
import { BackendInterface } from '../src/Backend';
5+
import { Response } from 'node-fetch';
6+
import { StandardElementDriver } from '../src/Component/ElementDriver';
7+
8+
const createComponent = (element: HTMLElement): Component => {
9+
const backend: BackendInterface = {
10+
makeRequest(): BackendRequest {
11+
return new BackendRequest(
12+
// @ts-ignore Response doesn't quite match the underlying interface
13+
new Promise((resolve) => resolve(new Response(''))),
14+
[],
15+
[]
16+
)
17+
}
18+
}
19+
20+
return new Component(
21+
element,
22+
{},
23+
{},
24+
null,
25+
null,
26+
backend,
27+
new StandardElementDriver(),
28+
);
29+
};
30+
31+
describe('ComponentRegistry', () => {
32+
it('can add and retrieve components', async () => {
33+
const element1 = document.createElement('div');
34+
const component1 = createComponent(element1);
35+
const element2 = document.createElement('div');
36+
const component2 = createComponent(element2);
37+
38+
ComponentRegistry.registerComponent(element1, component1);
39+
ComponentRegistry.registerComponent(element2, component2);
40+
41+
const promise1 = ComponentRegistry.getComponent(element1);
42+
const promise2 = ComponentRegistry.getComponent(element2);
43+
await expect(promise1).resolves.toBe(component1);
44+
await expect(promise2).resolves.toBe(component2);
45+
});
46+
47+
it('fails if component is not found soon', async () => {
48+
const element1 = document.createElement('div');
49+
const promise = ComponentRegistry.getComponent(element1);
50+
expect.assertions(1);
51+
await expect(promise).rejects.toEqual(new Error('Component not found for element <div></div>'));
52+
});
53+
});

src/LiveComponent/assets/test/controller/basic.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import {createTest, initComponent, shutdownTests, startStimulus} from '../tools';
1313
import { htmlToElement } from '../../src/dom_utils';
1414
import Component from '../../src/Component';
15+
import { getComponent } from '../../src/live_controller';
1516

1617
describe('LiveController Basic Tests', () => {
1718
afterEach(() => {
@@ -41,5 +42,6 @@ describe('LiveController Basic Tests', () => {
4142
expect(test.component.defaultDebounce).toEqual(115);
4243
expect(test.component.id).toEqual('the-id');
4344
expect(test.component.fingerprint).toEqual('the-fingerprint');
45+
await expect(getComponent(test.element)).resolves.toBe(test.component);
4446
});
4547
});

src/LiveComponent/doc/index.rst

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -416,14 +416,11 @@ controller and put it around (or attached to) your root component element:
416416
417417
// assets/controllers/some-custom-controller.js
418418
// ...
419+
import { getComponent } from '@symfony/ux-live-component';
419420
420421
export default class extends Controller {
421-
connect() {
422-
// when the live component inside of this controller is initialized,
423-
// this method will be called and you can access the Component object
424-
this.element.addEventListener('live:connect', (event) => {
425-
this.component = event.detail.component;
426-
});
422+
async initialize() {
423+
this.component = await getComponent(this.element);
427424
}
428425
429426
// some Stimulus action triggered, for example, on user click
@@ -441,11 +438,13 @@ controller and put it around (or attached to) your root component element:
441438
}
442439
443440
You can also access the ``Component`` object via a special property
444-
on the root component element:
441+
on the root component element, though ``getComponent()`` is the
442+
recommended way, as it will work even if the component is not yet
443+
initialized:
445444

446445
.. code-block:: javascript
447446
448-
const component = document.getElementById('id-on-your-element').__component;
447+
const component = document.getElementById('id-of-your-element').__component;
449448
component.mode = 'editing';
450449
451450
Finally, you can also set the value of a model field directly. However,
@@ -470,15 +469,14 @@ component system from Stimulus:
470469
471470
// assets/controllers/some-custom-controller.js
472471
// ...
472+
import { getComponent } from '@symfony/ux-live-component';
473473
474474
export default class extends Controller {
475-
connect() {
476-
this.element.addEventListener('live:connect', (event) => {
477-
this.component = event.detail.component;
475+
async initialize() {
476+
this.component = await getComponent(this.element);
478477
479-
this.component.on('render:finished', (component) => {
480-
// do something after the component re-renders
481-
});
478+
this.component.on('render:finished', (component) => {
479+
// do something after the component re-renders
482480
});
483481
}
484482
}

0 commit comments

Comments
 (0)