-
-
Notifications
You must be signed in to change notification settings - Fork 893
elements@1 - Auto-load custom-elements (web-components) from extensions #34348
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
Open
totten
wants to merge
1
commit into
civicrm:master
Choose a base branch
from
totten:master-element-mixin
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| shimmy-tag-a { | ||
| font-weight: bold; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| class ShimmyTagA extends HTMLElement { | ||
| connectedCallback() { | ||
| this.textContent = 'Hello world'; | ||
| } | ||
| } | ||
|
|
||
| customElements.define('shimmy-tag-a', TagA); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| class ShimmyTagB extends HTMLElement { | ||
| connectedCallback() { | ||
| this.textContent = 'Hello world'; | ||
| } | ||
| } | ||
|
|
||
| customElements.define('shimmy-tag-b', TagA); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| <?php | ||
|
|
||
| namespace Civi\Shimmy\Mixins; | ||
|
|
||
| /** | ||
| * Assert that the managed-entity mixin is working properly. | ||
| * | ||
| * This class defines the assertions to run when installing or uninstalling the extension. | ||
| * It use called as part of E2E_Shimmy_LifecycleTest. | ||
| * | ||
| * @see E2E_Shimmy_LifecycleTest | ||
| */ | ||
| class ElementsV1Test extends \PHPUnit\Framework\Assert { | ||
|
|
||
| public function testPreConditions($cv): void { | ||
| $this->assertFileExists(static::getPath('/element/shimmy-tag-a.js'), 'The shimmy extension must have example file shimmy-tag-a.js.'); | ||
| $this->assertFileExists(static::getPath('/element/shimmy-tag-a.css'), 'The shimmy extension must have example file shimmy-tag-a.css.'); | ||
| $this->assertFileExists(static::getPath('/element/shimmy-tag-b.mjs'), 'The shimmy extension must have example file shimmy-tag-b.mjs.'); | ||
| } | ||
|
|
||
| private function getAllElements($cv): array { | ||
| return $cv->phpEval(' | ||
| $svc = "elements@1"; | ||
| $c = \Civi::container(); | ||
| return $c->has($svc) ? $c->get($svc)->getAll() : []; | ||
| '); | ||
| } | ||
|
|
||
| public function testInstalled($cv): void { | ||
| $items = $this->trimFileNames($this->getAllElements($cv)); | ||
| $this->assertEquals(['shimmy/element/shimmy-tag-a.js'], $items['shimmy-tag-a']['js'] ?? 'MISSING', '<shimmy-tag-a> should have JS file'); | ||
| $this->assertEquals(['shimmy/element/shimmy-tag-a.css'], $items['shimmy-tag-a']['css'] ?? 'MISSING', '<shimmy-tag-a> should have CSS file'); | ||
| $this->assertEquals(['shimmy/element/shimmy-tag-b.mjs'], $items['shimmy-tag-b']['js'] ?? 'MISSING', '<shimmy-tag-b> should have JS file'); | ||
| $this->assertTrue(empty($items['shimmy-tag-b']['css']), 'shimmy-tag-b should not have any CSS'); | ||
| } | ||
|
|
||
| protected function trimFileNames($items): array { | ||
| foreach ($items as $elementName => &$element) { | ||
| foreach ($element as $field => &$value) { | ||
| if ($field === 'js' || $field === 'css') { | ||
| $value = array_map(fn($file) => preg_replace(';(\?ts=.*)$;', '', $file), $value); | ||
| } | ||
| } | ||
| } | ||
| return $items; | ||
| } | ||
|
|
||
| public function testDisabled($cv): void { | ||
| $items = $this->getAllElements($cv); | ||
| $this->assertTrue(!isset($items['shimmy-tag-a']), '<shimmy-tag-a> should not be defined.'); | ||
| $this->assertTrue(!isset($items['shimmy-tag-b']), '<shimmy-tag-b> should not be defined.'); | ||
| } | ||
|
|
||
| public function testUninstalled($cv): void { | ||
| $this->testDisabled($cv); | ||
| } | ||
|
|
||
| protected static function getPath($suffix = ''): string { | ||
| return dirname(__DIR__, 2) . $suffix; | ||
| } | ||
|
|
||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,257 @@ | ||||
| <?php | ||||
|
|
||||
| /** | ||||
| * Allow an extension to autoload "Custom Elements" (aka "Web Components") from *.js, *.mjs, *.css. | ||||
| * | ||||
| * Elements are loaded from eponymous files. For example: | ||||
| * | ||||
| * <hello-world> | ||||
| * ==> "$EXTENSION/element/hello-world.js" | ||||
| * ==> "$EXTENSION/element/hello-world.css" | ||||
| * | ||||
| * All [M]JS files are treated as ECMAScript Modules, so they may use `import` statements. | ||||
| * (In some deployments, `import`s may work with relative-paths. This is not currently guaranteed. | ||||
| * For stronger compatibility, imports SHOULD rely on the import-map prefix for `$EXTENSION/`.) | ||||
| * | ||||
| * If you need to register elements with different file-layouts, use hook_civicrm_elements(array &$elements). | ||||
| * | ||||
| * If you're doing funny business (like generating JS files) and need to refresh the index, call: | ||||
| * | ||||
| * Civi::service('elements@1')->flush(); | ||||
| * | ||||
| * @mixinName elements | ||||
| * @mixinVersion 1.0.0 | ||||
| * @since 6.11 | ||||
| * | ||||
| * Note: Requires hook_esmImportMap (v5.63+). | ||||
| * Note: Requires core mixin-loader (v5.45+; built-in version-handling). | ||||
| */ | ||||
| namespace Civi\Mixin\ElementsV1; | ||||
|
|
||||
| use Civi; | ||||
| use CRM_Utils_String; | ||||
| use Symfony\Component\DependencyInjection\ContainerBuilder; | ||||
| use Symfony\Component\DependencyInjection\Definition; | ||||
|
|
||||
| class Elements { | ||||
|
|
||||
| const ASSET = 'elements.js'; | ||||
|
|
||||
| const SUBDIR = 'element'; | ||||
|
|
||||
| protected $registered = FALSE; | ||||
|
|
||||
| public static function instance(): Elements { | ||||
| if (!isset(Civi::$statics[static::CLASS])) { | ||||
| Civi::$statics[static::CLASS] = new Elements(); | ||||
| } | ||||
| return Civi::$statics[static::CLASS]; | ||||
| } | ||||
|
|
||||
| public function register(): void { | ||||
| if ($this->registered) { | ||||
| return; | ||||
| } | ||||
| $this->registered = TRUE; | ||||
|
|
||||
| Civi::dispatcher()->addListener('&hook_civicrm_container', [$this, 'container']); | ||||
| Civi::dispatcher()->addListener('&hook_civicrm_buildAsset', [$this, 'buildAsset']); | ||||
| Civi::dispatcher()->addListener('&hook_civicrm_alterBundle', [$this, 'alterBundle']); | ||||
| } | ||||
|
|
||||
| /** | ||||
| * Register as a service in the container. | ||||
| * | ||||
| * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container | ||||
| * @return void | ||||
| */ | ||||
| public function container(ContainerBuilder $container): void { | ||||
| $container->setDefinition('elements@1', new Definition(static::CLASS)) | ||||
| ->setFactory([static::CLASS, 'instance']) | ||||
| ->setPublic(TRUE); | ||||
| } | ||||
|
|
||||
| /** | ||||
| * Render the `elements.js`, which provides an autoloader and registry. | ||||
| * | ||||
| * @internal | ||||
| * @see \CRM_Utils_Hook::buildAsset() | ||||
| */ | ||||
| public function buildAsset($asset, $params, &$mimeType, &$content) { | ||||
| if ($asset !== static::ASSET) { | ||||
| return; | ||||
| } | ||||
|
|
||||
| $mimeType = 'text/javascript'; | ||||
| $registry = $this->getAll(); | ||||
| $content = strtr($this->getTemplate(), [ | ||||
| 'ELEMENTS_REGISTRY' => json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normally we also don't escape unicode. civicrm-core/CRM/Utils/JSON.php Line 45 in 6eef79a
|
||||
| ]); | ||||
| } | ||||
|
|
||||
| /** | ||||
| * Add the `elements.js` autoloader to all regular page-views. | ||||
| * | ||||
| * @internal | ||||
| * @see \CRM_Utils_Hook::alterBundle() | ||||
| */ | ||||
| public function alterBundle(\CRM_Core_Resources_Bundle $bundle) { | ||||
| if ($bundle->name === 'coreResources') { | ||||
| $url = Civi::service('asset_builder')->getUrl(static::ASSET, ['id' => $this->getId()]); | ||||
| $bundle->addModuleUrl($url); | ||||
| // There might be some optimization opportunity to fetch the autoloader | ||||
| // in parallel with the main HTML doc. addModuleUrl() doesn't currently support | ||||
| // that (tho I suppose addMarkup() would). In any case, if you pursue that, then | ||||
| // also look closely at the init steps in `elements.js`. | ||||
| } | ||||
| } | ||||
|
|
||||
| /** | ||||
| * Get a list of all known elements. | ||||
| * | ||||
| * @api | ||||
| * @return array | ||||
| */ | ||||
| public function getAll(): array { | ||||
| $elements = []; | ||||
| $event = Civi\Core\Event\GenericHookEvent::create(['elements' => &$elements]); | ||||
| Civi::dispatcher()->dispatch('hook_civicrm_elements', $event); | ||||
| return $elements; | ||||
| } | ||||
|
|
||||
| /** | ||||
| * @return string | ||||
| */ | ||||
| public function getId(): string { | ||||
| $id = Civi::cache('long')->get('elements-id'); | ||||
| if ($id === NULL) { | ||||
| $id = CRM_Utils_String::createRandom(8, CRM_Utils_String::ALPHANUMERIC); | ||||
| Civi::cache('long')->set('elements-id', $id, 7 * 24 * 60 * 60); | ||||
| } | ||||
| return $id; | ||||
| } | ||||
|
|
||||
| /** | ||||
| * @api | ||||
| */ | ||||
| public function flush(): void { | ||||
| Civi::cache('long')->delete('elements-id'); | ||||
| } | ||||
|
|
||||
| /** | ||||
| * @return string | ||||
| */ | ||||
| private function getTemplate(): string { | ||||
| $fp = fopen(__FILE__, 'r'); | ||||
| fseek($fp, __COMPILER_HALT_OFFSET__); | ||||
| $template = stream_get_contents($fp); | ||||
| fclose($fp); | ||||
| return $template; | ||||
| } | ||||
|
|
||||
| } | ||||
|
|
||||
| /** | ||||
| * As a mixin, we receive a notification for each extension that enables `elements@1`. | ||||
| * | ||||
| * @param \CRM_Extension_MixInfo $mixInfo | ||||
| * @param \CRM_Extension_BootCache $bootCache | ||||
| */ | ||||
| return function ($mixInfo, $bootCache) { | ||||
|
|
||||
| Elements::instance()->register(); | ||||
|
|
||||
| /** | ||||
| * Register this extension with the ESM import-map ('my-extension/' => '/var/www/sites/default/civicrm/ext/my-extension'). | ||||
| * | ||||
| * @see \CRM_Utils_Hook::esmImportMap() | ||||
| */ | ||||
| Civi::dispatcher()->addListener('&hook_civicrm_esmImportMap', function(\Civi\Esm\ImportMap $importMap) use ($mixInfo) { | ||||
| if ($mixInfo->isActive()) { | ||||
| $importMap->addPrefix($mixInfo->longName . '/', $mixInfo->longName); | ||||
| } | ||||
| }, 500); | ||||
|
|
||||
| /** | ||||
| * Statically register each *.js, *.mjs, *.css file from $EXTENSION/element/. | ||||
| */ | ||||
| Civi::dispatcher()->addListener('&hook_civicrm_elements', function(array &$elements) use ($mixInfo) { | ||||
| if ($mixInfo->isActive()) { | ||||
| $fileTypes = [ | ||||
| '[a-z]*-*.js' => 'js', | ||||
| '[a-z]*-*.mjs' => 'js', | ||||
| '[a-z]*-*.css' => 'css', | ||||
| ]; | ||||
| foreach ($fileTypes as $filePattern => $fileType) { | ||||
| $files = (array) glob($mixInfo->getPath(Elements::SUBDIR . '/' . $filePattern)); | ||||
| foreach ($files as $file) { | ||||
| $elementName = preg_replace('/\.(js|mjs|css)$/', '', basename($file)); | ||||
| if (!isset($elements[$elementName][$fileType])) { | ||||
| $elements[$elementName][$fileType] = []; | ||||
| } | ||||
| $elements[$elementName][$fileType][] = $mixInfo->longName . '/' . Elements::SUBDIR . '/' . basename($file) . '?ts=' . filemtime($file); | ||||
| } | ||||
| } | ||||
| } | ||||
| }, 1000); | ||||
|
|
||||
| }; | ||||
|
|
||||
| ############################################################################### | ||||
| ## Below, we stop executing PHP. The rest of the file contains the template | ||||
| ## for "elements.js". | ||||
| __HALT_COMPILER(); | ||||
| const loading = new Set(); | ||||
| const registry = window.CRM.elements = ELEMENTS_REGISTRY; | ||||
|
|
||||
| async function maybeLoad(tagName) { | ||||
| if (!registry[tagName] || customElements.get(tagName) || loading.has(tagName)) { | ||||
| return; | ||||
| } | ||||
|
|
||||
| loading.add(tagName); | ||||
| const { js = [], css = [] } = registry[tagName]; | ||||
|
|
||||
| try { | ||||
| css.forEach(logicalUri => { | ||||
| const href = import.meta.resolve(logicalUri); | ||||
| if (!document.querySelector(`link[href="${href}"]`)) { | ||||
| const link = document.createElement('link'); | ||||
| link.rel = 'stylesheet'; | ||||
| link.href = href; | ||||
| document.head.appendChild(link); | ||||
| } | ||||
| }); | ||||
|
|
||||
| await Promise.all(js.map(src => import(src))); | ||||
|
|
||||
| } catch (err) { | ||||
| console.error(`Failed to load assets for ${tagName}`, err); | ||||
| // Remove from loading set so a retry can be attempted later if needed | ||||
| loading.delete(tagName); | ||||
| } | ||||
| } | ||||
|
|
||||
| // Scan initial DOM | ||||
| document.querySelectorAll('*').forEach(el => { | ||||
| maybeLoad(el.tagName.toLowerCase()); | ||||
| }); | ||||
|
|
||||
| // Watch for new nodes | ||||
| const observer = new MutationObserver(mutations => { | ||||
| for (const m of mutations) { | ||||
| for (const node of m.addedNodes) { | ||||
| if (node.nodeType === 1) { | ||||
| maybeLoad(node.tagName.toLowerCase()); | ||||
| node.querySelectorAll?.('*').forEach(el => | ||||
| maybeLoad(el.tagName.toLowerCase()) | ||||
| ); | ||||
| } | ||||
| } | ||||
| } | ||||
| }); | ||||
|
|
||||
| observer.observe(document.documentElement, { | ||||
| childList: true, | ||||
| subtree: true, | ||||
| }); | ||||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it is slightly annoying how inflections vary within Civi. My first reaction was: "Right, consistency is good; fine, let's rename it". And I kind of like how
./elements/resembles./templates/. To implement this, it does need a slightly bigger commit:elements@1+./elements/): 8bf36ecBut I'm a sucker for this kind of thing. If we're going for consistency, then one should look the existing top-level folders and mixins:
So... I think that singular is more consistent, in which case -- here's the commit going the other direction:
element@1+./element/): 1decafdOf course, there's a lot that we inherit from upstream ecosystems --
bower_component[s]/,vendor[s]/,js/,css/,bin[s]/, etc -- and they don't point in consistent directions either...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, I fear we will have this argument until the end of time...
My view is consistent at least: plurals are more human readable, and thereby well worth one extra character. Anything like this we add should use plurals.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My vote would also be for
elementswhich is consistent with the siblingsettingsandtemplatesdirectories.But whichever we do we should merge this PR and get on with our lives.