diff --git a/experience_builder.api.php b/experience_builder.api.php index 3713bc8a547d4f4bd6192f2b0c4143af22c6db1d..de1f1ddbf1af377861f0cb74c6dce10965ea77a6 100644 --- a/experience_builder.api.php +++ b/experience_builder.api.php @@ -59,6 +59,8 @@ use Drupal\experience_builder\PropShape\CandidateStorablePropShape; * XB Extensions makes additional functionalities and customization points * available for extending the Experience Builder module. * + * XB Extensions can only be defined in modules. Themes are not supported. + * * Any library with `drupalSettings.xbExtension` will be identified as an * Experience Builder extension and will be loaded with the UI. Be sure * to add `experience_builder/ui` as a dependency. @@ -71,9 +73,14 @@ use Drupal\experience_builder\PropShape\CandidateStorablePropShape; * attributes: { type: module } * drupalSettings: * xbExtension: - * testExtension: { id: 'experience-builder-test-extension', name: 'XB Test Extension' } + * testExtension: { + * id: 'experience-builder-test-extension', + * name: 'XB Test Extension', + * description: 'A test extension for Experience Builder.', + * imgSrc: 'relative/path/from/your/module/optionalImage.png' + * } * dependencies: - * - experience_builder/ui + * - experience_builder/xb-ui * * @see tests/modules/xb_test_extension/ui/index.jsx for how to wrap your * React Application so it has access to Experience Builder UI APIs diff --git a/experience_builder.extensions.inc b/experience_builder.extensions.inc new file mode 100644 index 0000000000000000000000000000000000000000..74457e03b7c3dd4142039ff8a8aa61abf853fb9c --- /dev/null +++ b/experience_builder.extensions.inc @@ -0,0 +1,79 @@ +<?php + +/** + * @file + * Hook implementations for Experience Builder extensions. + */ + +use Drupal\Core\Discovery\YamlDiscovery; +use Drupal\Core\Url; + +/** + * Adds libraries that extend Experience Builder to a centralized library. + * + * @param array $libraries + * The libraries array. + */ +function _experience_builder_build_extension_libraries(array &$libraries): void { + $core_discovery = new YamlDiscovery('libraries', \Drupal::service('module_handler')->getModuleDirectories()); + + $xb_extensions = []; + + // Get all libraries with xbExtension defined in drupalSettings. + foreach ($core_discovery->findAll() as $module_name => $module_libraries) { + foreach ($module_libraries as $library_name => $library) { + if (isset($library['drupalSettings']['xbExtension'])) { + $xb_extensions[] = $module_name . '/' . $library_name; + } + } + } + + // Add the libraries as dependencies of experience_builder/extensions. + if (!empty($xb_extensions)) { + $libraries['extensions'] = [ + 'dependencies' => $xb_extensions, + ]; + } +} + +/** + * Process module paths for all extensions. + * + * This is called from experience_builder_library_info_alter() + * in the main module file. + */ +function _experience_builder_process_extensions_paths(array &$libraries, string $extension): void { + $module_path_service = \Drupal::service('extension.list.module'); + + // Find all libraries that specify xbExtension in drupalSettings and provide + // default values and image paths. + foreach ($libraries as &$library) { + if (!isset($library['drupalSettings']['xbExtension'])) { + continue; + } + + foreach ($library['drupalSettings']['xbExtension'] as &$extension_settings) { + $module_path = $module_path_service->getPath($extension); + $extension_settings['modulePath'] = $module_path; + + assert(!empty($extension_settings['id']), "The xbExtension config in $extension must have an 'id' property."); + assert(!empty($extension_settings['name']), "The xbExtension config in $extension must have a 'name' property."); + + if (empty($extension_settings['description'])) { + $extension_settings['description'] = t('No description provided.'); + } + + if (!isset($extension_settings['imgSrc'])) { + continue; + } + + $img_src = $extension_settings['imgSrc']; + + // Only prepend the path if it's a relative path without a leading slash + if (!str_starts_with($img_src, '/') && !str_starts_with($img_src, 'http')) { + assert(!str_starts_with($img_src, '.'), 'The extension image path must not start with "."'); + $extension_settings['imgSrc'] = Url::fromUri('base://' . $module_path . '/' . $img_src)->toString(); + } + } + } +} diff --git a/experience_builder.module b/experience_builder.module index 8b0e09e820ac4c311471222b1876f38f96ba1a77..23fd69b125f7cfe8343bef52fa92922c969b6fc8 100644 --- a/experience_builder.module +++ b/experience_builder.module @@ -22,7 +22,6 @@ use Drupal\Core\EventSubscriber\AjaxResponseSubscriber; use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Plugin\Discovery\YamlDiscovery; use Drupal\Core\Render\Markup; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; use Drupal\experience_builder\Entity\AssetLibrary; @@ -53,6 +52,8 @@ require_once __DIR__ . '/experience_builder.auto_save.inc'; require_once __DIR__ . '/experience_builder.force_render_with_twig.inc'; // @see docs/components.md, section 3.3.3 require_once __DIR__ . '/experience_builder.block_overrides.inc'; +// Experience Builder extensions support +require_once __DIR__ . '/experience_builder.extensions.inc'; /** * Implements hook_preprocess_HOOK() for regions. @@ -545,33 +546,6 @@ function _experience_builder_base_library_build(array &$libraries, array $defaul } } -/** - * Adds libraries that extend Experience Builder to a centralized library. - * - * @param array $libraries - * The libraries array. - * - * @return void - */ -function _experience_builder_build_extension_libraries(array &$libraries): void { - $libraries_discovery = new YamlDiscovery('libraries', \Drupal::service('module_handler')->getModuleDirectories()); - - // Any library with a 'xbExtension' drupalSettings will be added. - $xb_extensions = array_reduce($libraries_discovery->getDefinitions(), function ($carry, $item) { - if (isset($item['drupalSettings']['xbExtension'])) { - $carry[] = $item['provider'] . '/' . $item['id']; - } - return $carry; - }, []); - - // Add the libraries as dependencies of experience_builder/extensions. - if (!empty($xb_extensions)) { - $libraries['extensions'] = [ - 'dependencies' => $xb_extensions, - ]; - } -} - /** * Customize core/drupal.dialog for Experience Builder. * @@ -792,6 +766,14 @@ function experience_builder_entity_form_display_alter(EntityFormDisplayInterface } } +/** + * Implements hook_library_info_alter(). + */ +function experience_builder_library_info_alter(array &$libraries, string $extension): void { + _experience_builder_process_transforms($libraries, $extension); + _experience_builder_process_extensions_paths($libraries, $extension); +} + /** * Implements hook_entity_type_alter(). */ diff --git a/experience_builder.redux_integrated_field_widgets.inc b/experience_builder.redux_integrated_field_widgets.inc index 3a079482536a703de32a1e2727546827a44ee72c..e5cb6a95e01bd588c59a262bce646dfb16b1ac4f 100644 --- a/experience_builder.redux_integrated_field_widgets.inc +++ b/experience_builder.redux_integrated_field_widgets.inc @@ -18,7 +18,7 @@ use Drupal\media_library\MediaLibraryState; /** * Implements hook_library_info_alter(). */ -function experience_builder_library_info_alter(array &$libraries, string $extension): void { +function _experience_builder_process_transforms(array &$libraries, string $extension): void { if ($extension === 'experience_builder') { // We need to dynamically create a 'transforms' library by compiling a list // of all module defined transforms - which are libraries prefixed with diff --git a/tests/modules/xb_test_extension/xb_test_extension.libraries.yml b/tests/modules/xb_test_extension/xb_test_extension.libraries.yml index 020c18c9196fa0f33fed302507fe7d5933215935..fad863d67b7b721f58f93fe25f80ebbf05f8508f 100644 --- a/tests/modules/xb_test_extension/xb_test_extension.libraries.yml +++ b/tests/modules/xb_test_extension/xb_test_extension.libraries.yml @@ -8,4 +8,4 @@ app: xbExtension: testExtension: { id: 'experience-builder-test-extension', name: 'XB Test Extension', description: 'Demonstrates many things that an XB extension can do' } dependencies: - - experience_builder/ui + - experience_builder/xb-ui diff --git a/ui/assets/icons/extension-default-abstract.svg b/ui/assets/icons/extension-default-abstract.svg new file mode 100644 index 0000000000000000000000000000000000000000..ea732874d7b96ae64635b4a487bef7b4f3732566 --- /dev/null +++ b/ui/assets/icons/extension-default-abstract.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="currentColor"> + <path + d="M120-380q-8 0-14-6t-6-14q0-8 6-14t14-6q8 0 14 6t6 14q0 8-6 14t-14 6zm0-160q-8 0-14-6t-6-14q0-8 6-14t14-6q8 0 14 6t6 14q0 8-6 14t-14 6zm120 340q-17 0-28.5-11.5T200-240q0-17 11.5-28.5T240-280q17 0 28.5 11.5T280-240q0 17-11.5 28.5T240-200zm0-160q-17 0-28.5-11.5T200-400q0-17 11.5-28.5T240-440q17 0 28.5 11.5T280-400q0 17-11.5 28.5T240-360zm0-160q-17 0-28.5-11.5T200-560q0-17 11.5-28.5T240-600q17 0 28.5 11.5T280-560q0 17-11.5 28.5T240-520zm0-160q-17 0-28.5-11.5T200-720q0-17 11.5-28.5T240-760q17 0 28.5 11.5T280-720q0 17-11.5 28.5T240-680zm160 340q-25 0-42.5-17.5T340-400q0-25 17.5-42.5T400-460q25 0 42.5 17.5T460-400q0 25-17.5 42.5T400-340zm0-160q-25 0-42.5-17.5T340-560q0-25 17.5-42.5T400-620q25 0 42.5 17.5T460-560q0 25-17.5 42.5T400-500zm0 300q-17 0-28.5-11.5T360-240q0-17 11.5-28.5T400-280q17 0 28.5 11.5T440-240q0 17-11.5 28.5T400-200zm0-480q-17 0-28.5-11.5T360-720q0-17 11.5-28.5T400-760q17 0 28.5 11.5T440-720q0 17-11.5 28.5T400-680zm0 580q-8 0-14-6t-6-14q0-8 6-14t14-6q8 0 14 6t6 14q0 8-6 14t-14 6zm0-720q-8 0-14-6t-6-14q0-8 6-14t14-6q8 0 14 6t6 14q0 8-6 14t-14 6zm160 480q-25 0-42.5-17.5T500-400q0-25 17.5-42.5T560-460q25 0 42.5 17.5T620-400q0 25-17.5 42.5T560-340zm0-160q-25 0-42.5-17.5T500-560q0-25 17.5-42.5T560-620q25 0 42.5 17.5T620-560q0 25-17.5 42.5T560-500zm0 300q-17 0-28.5-11.5T520-240q0-17 11.5-28.5T560-280q17 0 28.5 11.5T600-240q0 17-11.5 28.5T560-200zm0-480q-17 0-28.5-11.5T520-720q0-17 11.5-28.5T560-760q17 0 28.5 11.5T600-720q0 17-11.5 28.5T560-680zm0 580q-8 0-14-6t-6-14q0-8 6-14t14-6q8 0 14 6t6 14q0 8-6 14t-14 6zm0-720q-8 0-14-6t-6-14q0-8 6-14t14-6q8 0 14 6t6 14q0 8-6 14t-14 6zm160 620q-17 0-28.5-11.5T680-240q0-17 11.5-28.5T720-280q17 0 28.5 11.5T760-240q0 17-11.5 28.5T720-200zm0-160q-17 0-28.5-11.5T680-400q0-17 11.5-28.5T720-440q17 0 28.5 11.5T760-400q0 17-11.5 28.5T720-360zm0-160q-17 0-28.5-11.5T680-560q0-17 11.5-28.5T720-600q17 0 28.5 11.5T760-560q0 17-11.5 28.5T720-520zm0-160q-17 0-28.5-11.5T680-720q0-17 11.5-28.5T720-760q17 0 28.5 11.5T760-720q0 17-11.5 28.5T720-680zm120 300q-8 0-14-6t-6-14q0-8 6-14t14-6q8 0 14 6t6 14q0 8-6 14t-14 6zm0-160q-8 0-14-6t-6-14q0-8 6-14t14-6q8 0 14 6t6 14q0 8-6 14t-14 6z" /> +</svg> \ No newline at end of file diff --git a/ui/src/app/store.ts b/ui/src/app/store.ts index fb6ea2e4bb1181ef79aac00bf25d5808c34264d5..3fabaa0100df53adf8facaaa7d553d0eaf98ca73 100644 --- a/ui/src/app/store.ts +++ b/ui/src/app/store.ts @@ -31,7 +31,6 @@ import { pageDataFormApi } from '@/services/pageDataForm'; import { configurationSlice } from '@/features/configuration/configurationSlice'; import { sectionApi } from '@/services/sections'; import { extensionsSlice } from '@/features/extensions/extensionsSlice'; -import { extensionsApi } from '@/services/extensions'; import { assetLibraryApi } from '@/services/assetLibrary'; import { componentAndLayoutApi } from '@/services/componentAndLayout'; import { formStateSlice } from '@/features/form/formStateSlice'; @@ -126,7 +125,6 @@ const rootReducer = combineSlices( ), }, sectionApi, - extensionsApi, assetLibraryApi, componentAndLayoutApi, previewApi, @@ -195,7 +193,6 @@ export const makeStore = (preloadedState?: Partial<RootState>) => { middleware: (getDefaultMiddleware) => { return getDefaultMiddleware().concat( sectionApi.middleware, - extensionsApi.middleware, assetLibraryApi.middleware, componentAndLayoutApi.middleware, previewApi.middleware, diff --git a/ui/src/components/extensions/ExtensionsList.tsx b/ui/src/components/extensions/ExtensionsList.tsx index ccd2b51971699dfd0c875a2bb84b0ef6d1cb8367..6ca53731e863dfd9be48316a8ee1083573351d16 100644 --- a/ui/src/components/extensions/ExtensionsList.tsx +++ b/ui/src/components/extensions/ExtensionsList.tsx @@ -1,43 +1,37 @@ -import { Flex, Heading, Link, Grid, Spinner } from '@radix-ui/themes'; +import { Flex, Heading, Link, Grid } from '@radix-ui/themes'; import { ExternalLinkIcon } from '@radix-ui/react-icons'; import ExtensionButton from '@/components/extensions/ExtensionButton'; import { handleNonWorkingBtn } from '@/utils/function-utils'; import type React from 'react'; -import { useGetExtensionsQuery } from '@/services/extensions'; -import ErrorCard from '@/components/error/ErrorCard'; interface ExtensionsPopoverProps {} +const { drupalSettings } = window; + const ExtensionsList: React.FC<ExtensionsPopoverProps> = () => { - const { - data: extensions, - isError, - isLoading, - refetch, - } = useGetExtensionsQuery(); + let extensionsList = []; + if (drupalSettings && drupalSettings.xbExtension) { + extensionsList = Object.values(drupalSettings.xbExtension).map((value) => { + return { + ...value, + imgSrc: + value.imgSrc || + `${drupalSettings.path.baseUrl}${drupalSettings.xb.xbModulePath}/ui/assets/icons/extension-default-abstract.svg`, + name: value.name, + description: value.description, + }; + }); + } - return ( - <ExtensionsListDisplay - extensions={extensions || []} - isLoading={isLoading} - isError={isError} - refetch={refetch} - /> - ); + return <ExtensionsListDisplay extensions={extensionsList || []} />; }; interface ExtensionsListDisplayProps { extensions: Array<any>; - isLoading: boolean; - isError: boolean; - refetch: () => void; } const ExtensionsListDisplay: React.FC<ExtensionsListDisplayProps> = ({ extensions, - isLoading, - isError, - refetch, }) => { return ( <> @@ -60,26 +54,19 @@ const ExtensionsListDisplay: React.FC<ExtensionsListDisplayProps> = ({ </Link> </Flex> </Flex> - {isError && ( - <ErrorCard - error="Cannot display extensions, please try again." - resetErrorBoundary={refetch} - resetButtonText="Try again" - title="Error loading extensions" - /> - )} - {isLoading && ( - <Flex justify="center"> - <Spinner /> - </Flex> - )} - {!isError && extensions && ( + + {extensions.length > 0 && ( <Grid columns="3" gap="3"> {extensions.map((extension) => ( <ExtensionButton extension={extension} key={extension.id} /> ))} </Grid> )} + {extensions?.length === 0 && ( + <Flex justify="center"> + <p>No extensions found</p> + </Flex> + )} </> ); }; diff --git a/ui/src/components/extensions/ExtensionsListDisplay.stories.tsx b/ui/src/components/extensions/ExtensionsListDisplay.stories.tsx index 45b4f8b9a9f6319883d96b98e4818c68e000c81f..3191e4f38e817b619b7adf12fba998def3de858f 100644 --- a/ui/src/components/extensions/ExtensionsListDisplay.stories.tsx +++ b/ui/src/components/extensions/ExtensionsListDisplay.stories.tsx @@ -38,20 +38,11 @@ const meta: Meta<typeof ExtensionsListDisplay> = { component: ExtensionsListDisplay, args: { extensions: mockExtensions, - isLoading: false, - isError: false, - refetch: () => {}, }, argTypes: { extensions: { control: { type: 'object' }, }, - isLoading: { - control: { type: 'boolean' }, - }, - isError: { - control: { type: 'boolean' }, - }, }, decorators: [ (Story) => ( @@ -73,23 +64,8 @@ export default meta; type Story = StoryObj<typeof ExtensionsListDisplay>; export const Default: Story = {}; - -export const Loading: Story = { +export const Empty: Story = { args: { extensions: [], - isLoading: true, - isError: false, - refetch: () => {}, - }, -}; - -export const Error: Story = { - args: { - extensions: [], - isLoading: false, - isError: true, - refetch: () => { - alert('Dummy refetch'); - }, }, }; diff --git a/ui/src/services/extensions.ts b/ui/src/services/extensions.ts deleted file mode 100644 index 3f29522d3ab850444fb210e1e47bfa4851b1fd01..0000000000000000000000000000000000000000 --- a/ui/src/services/extensions.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Need to use the React-specific entry point to import createApi -import { createApi } from '@reduxjs/toolkit/query/react'; -import { baseQuery } from '@/services/baseQuery'; -import type { ExtensionsList } from '@/types/Extensions'; -const { drupalSettings } = window; -const kittenBase64 = - /* cspell:disable-next-line */ - 'data:image/jpeg;base64, /9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAYABgDASIAAhEBAxEB/8QAGAABAAMBAAAAAAAAAAAAAAAAAAMGBwX/xAAlEAABBAEDBAIDAAAAAAAAAAABAgMEEQAFEiEGBzFBE1EUFSL/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBBf/EAB4RAAICAgIDAAAAAAAAAAAAAAECAAMRIQQSIlJx/9oADAMBAAIRAxEAPwDPOopUeU604VNEPLKFniwFKJonOXqHS+pIeUzA02VIbcaUW1CI4vddGhQo5pR1TTdPYBg6HCbktuBTTxWtexI9Uu+fP9CssumddCe3KQ18rEptvei3B4FCxfF2br6vA5Nl1ewmvsuTg68jiWruVcHssy2sbVtwWm1JqqIYIIr1z6xmS9R9wP2kB6JqUZJU2C2+x+QfkVzR4Io8349YzKncjJWJaj1Mpz09aydxvn3kAnKaeQ42va6k2D9EYxnRbcvLEjBkM+U5qGoLmzZC35KwApaqs1wPAxjGAABoRagKOqjAn//Z'; - -// @todo stop hardcoding this list - https://www.drupal.org/i/3509080 -let dummyExtensionsList = [ - { - name: 'Extension proof of concept', - description: - 'Enable the xb_test_extension module to make this extension appear', - imgSrc: kittenBase64, - id: 'experience-builder-test-extension', - }, - { - name: 'Extension with longer name 2', - description: "A dummy extension. It doesn't do anything.", - imgSrc: kittenBase64, - id: 'extension2', - }, - { - name: 'Extension 3', - description: 'Another dummy extension. It does nothing.', - imgSrc: kittenBase64, - id: 'extension3', - }, - { - name: 'Extension 4', - description: 'This is a dummy extension that does nothing.', - imgSrc: kittenBase64, - id: 'extension4', - }, -]; - -if (drupalSettings && drupalSettings.xbExtension) { - Object.entries(drupalSettings.xbExtension).forEach(([key, value]) => { - dummyExtensionsList = dummyExtensionsList.map((item) => { - if (item.id === value.id) { - return { ...item, ...value }; - } - return item; - }); - }); -} - -// Custom baseQuery function to return mock data during development -// @ts-ignore -const customBaseQuery = async (args, api, extraOptions) => { - if (args === 'xb-extensions') { - return { data: dummyExtensionsList }; - } - return baseQuery(args, api, extraOptions); -}; - -// Define a service using a base URL and expected endpoints -export const extensionsApi = createApi({ - reducerPath: 'extensionsApi', - baseQuery: customBaseQuery, - endpoints: (builder) => ({ - getExtensions: builder.query<ExtensionsList, void>({ - query: () => `xb-extensions`, - }), - }), -}); - -// Export hooks for usage in functional extensions, which are -// auto-generated based on the defined endpoints -export const { useGetExtensionsQuery } = extensionsApi;