diff --git a/ui/src/components/ComponentPreview.tsx b/ui/src/components/ComponentPreview.tsx index fb33b82f4cb3561fd2dd06247024543b0da57291..fc0141c1f77c7f73f2b8331a683e0c7e4c3b7279 100644 --- a/ui/src/components/ComponentPreview.tsx +++ b/ui/src/components/ComponentPreview.tsx @@ -3,13 +3,11 @@ import { useEffect } from 'react'; import styles from './ComponentPreview.module.css'; import clsx from 'clsx'; import Panel from '@/components/Panel'; -import type { - ComponentListItem, - SectionListItem, -} from '@/components/list/List'; +import type { XBComponent } from '@/types/Component'; +import type { Section } from '@/types/Section'; interface ComponentPreviewProps { - componentListItem: ComponentListItem | SectionListItem; + componentListItem: XBComponent | Section; } const { drupalSettings } = window; diff --git a/ui/src/components/DummyPropsEditForm.tsx b/ui/src/components/DummyPropsEditForm.tsx index d95db000f69811e6a9188bf9b8f68b0b51164f24..33e8c04f9e05c37c2b57c717cc1370b0c7e5cf4f 100644 --- a/ui/src/components/DummyPropsEditForm.tsx +++ b/ui/src/components/DummyPropsEditForm.tsx @@ -21,7 +21,7 @@ import { useDrupalBehaviors } from '@/hooks/useDrupalBehaviors'; import useXbParams from '@/hooks/useXbParams'; import { clearFieldValues } from '@/features/form/formStateSlice'; import type { FieldData } from '@/types/Component'; -import type { Component } from '@/types/Component'; +import type { XBComponent } from '@/types/Component'; import { componentHasFieldData } from '@/types/Component'; import type { AjaxUpdateFormStateEvent } from '@/types/Ajax'; import { AJAX_UPDATE_FORM_STATE_EVENT } from '@/types/Ajax'; @@ -229,7 +229,7 @@ const DummyPropsEditForm: React.FC<DummyPropsEditFormProps> = () => { const buildPreparedModel = ( model: ComponentModel, - component: Component, + component: XBComponent, ): ComponentModel => { if (!componentHasFieldData(component)) { return model; diff --git a/ui/src/components/dynamicComponents/DynamicComponents.tsx b/ui/src/components/dynamicComponents/DynamicComponents.tsx new file mode 100644 index 0000000000000000000000000000000000000000..09c985d5c5c12d5036695053e95d5b51fb76aa95 --- /dev/null +++ b/ui/src/components/dynamicComponents/DynamicComponents.tsx @@ -0,0 +1,56 @@ +import { Flex, Heading, Box, ScrollArea, Spinner } from '@radix-ui/themes'; +import type React from 'react'; + +import DynamicComponentsLibrary from '@/components/dynamicComponents/DynamicComponentsLibrary'; +import { useGetComponentsQuery } from '@/services/componentAndLayout'; +import ErrorCard from '@/components/error/ErrorCard'; + +interface DynamicComponentsGroupsPopoverProps {} + +const DynamicComponents: React.FC<DynamicComponentsGroupsPopoverProps> = () => { + const { + data: dynamicComponents, + isLoading, + isError, + isFetching, + refetch, + } = useGetComponentsQuery({ + mode: 'include', + libraries: ['dynamic_components'], + }); + + if (isLoading || isFetching) { + return ( + <Flex justify="center" align="center"> + <Spinner /> + </Flex> + ); + } + + return ( + <> + <Flex justify="between"> + <Heading as="h3" size="3" mb="4"> + Dynamic Components + </Heading> + </Flex> + <Box mr="-4"> + <ScrollArea style={{ maxHeight: '380px', width: '100%' }} type="scroll"> + <Box pr="4" mt="3"> + {isError && ( + <ErrorCard + title="Error fetching Dynamic component list." + resetErrorBoundary={refetch} + /> + )} + {!isError && dynamicComponents && ( + <DynamicComponentsLibrary dynamicComponents={dynamicComponents} /> + )} + </Box> + </ScrollArea> + </Box> + </> + ); +}; + +export default DynamicComponents; diff --git a/ui/src/components/dynamicComponents/DynamicComponentsLibrary.tsx b/ui/src/components/dynamicComponents/DynamicComponentsLibrary.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0f7241e2e6a262e45d201cec28501501156ae2da --- /dev/null +++ b/ui/src/components/dynamicComponents/DynamicComponentsLibrary.tsx @@ -0,0 +1,80 @@ +import type React from 'react'; +import { + AccordionRoot, + AccordionDetails, +} from '@/components/form/components/Accordion'; +import ErrorBoundary from '@/components/error/ErrorBoundary'; +import { setOpenLayoutItem } from '@/features/ui/primaryPanelSlice'; +import styles from '@/components/sidebar/Library.module.css'; +import { useState } from 'react'; +import List from '@/components/list/List'; +import type { ComponentsList } from '@/types/Component'; + +interface LibraryProps { + dynamicComponents: ComponentsList; +} + +const Library: React.FC<LibraryProps> = ({ dynamicComponents }) => { + const [openCategories, setOpenCategories] = useState<string[]>([]); + + const categoriesSet = new Set<string>(); + Object.values(dynamicComponents).forEach((component) => { + categoriesSet.add(component.category); + }); + + const categories = Array.from(categoriesSet) + .map((categoryName) => { + return { + name: categoryName.replace(/\w/, (c) => c.toUpperCase()), + id: categoryName, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const onClickHandler = (categoryName: string) => { + setOpenCategories((state) => { + if (!state.includes(categoryName)) { + return [...state, categoryName]; + } + return state.filter((stateName) => stateName !== categoryName); + }); + }; + + return ( + <> + <AccordionRoot + value={openCategories} + onValueChange={() => setOpenLayoutItem} + > + {categories.map((category) => ( + <AccordionDetails + key={category.id} + value={category.id} + title={category.name} + onTriggerClick={() => onClickHandler(category.id)} + className={styles.accordionDetails} + triggerClassName={styles.accordionDetailsTrigger} + > + <ErrorBoundary + title={`An unexpected error has occurred while fetching ${category.name}.`} + > + <List + // filtered dynamicComponents that match the current category + items={Object.fromEntries( + Object.entries(dynamicComponents).filter( + ([key, component]) => component.category === category.id, + ), + )} + isLoading={false} + type="component" + label="Components" + /> + </ErrorBoundary> + </AccordionDetails> + ))} + </AccordionRoot> + </> + ); +}; + +export default Library; diff --git a/ui/src/components/form/components/Accordion.tsx b/ui/src/components/form/components/Accordion.tsx index 7852daa52d631d432a96938700fc275180c75af0..f5448f68ea00460cc46f6027b75d58b37aafbd33 100644 --- a/ui/src/components/form/components/Accordion.tsx +++ b/ui/src/components/form/components/Accordion.tsx @@ -58,9 +58,14 @@ const AccordionDetails = ({ className={clsx(styles.trigger, triggerClassName)} onClick={onTriggerClick} > - <Text size="2" weight="medium" {...summaryAttributes}> - {title} - </Text> + {typeof title === 'string' ? ( + <Text size="2" weight="medium" {...summaryAttributes}> + {title} + </Text> + ) : ( + title + )} + <ChevronRightIcon className={styles.chevron} aria-hidden /> </Accordion.Trigger> </Flex> diff --git a/ui/src/components/form/inputBehaviors.tsx b/ui/src/components/form/inputBehaviors.tsx index c890cd7e4c55e40271c948abb18be3c3bd6825a7..68461d5f18073d47ccc6db235b792b86f0f1efbb 100644 --- a/ui/src/components/form/inputBehaviors.tsx +++ b/ui/src/components/form/inputBehaviors.tsx @@ -42,7 +42,7 @@ import { clearFieldError, } from '@/features/form/formStateSlice'; import type { ErrorObject } from 'ajv/dist/types'; -import type { Component } from '@/types/Component'; +import type { XBComponent } from '@/types/Component'; import { componentHasFieldData } from '@/types/Component'; import { FORM_TYPES } from '@/features/form/constants'; import { useUpdateComponentMutation } from '@/services/preview'; @@ -462,7 +462,7 @@ export default InputBehaviors; export const syncPropSourcesToResolvedValues = ( sources: Sources, - component: Component, + component: XBComponent, resolvedValues: ResolvedValues, ): Sources => { if (!componentHasFieldData(component)) { diff --git a/ui/src/components/list/ComponentList.tsx b/ui/src/components/list/ComponentList.tsx index 03c571c7389c8643a8cac5c738002eab6adb68a4..56d47f092e63e7668e5f4c17c5f2a12b0632fcfa 100644 --- a/ui/src/components/list/ComponentList.tsx +++ b/ui/src/components/list/ComponentList.tsx @@ -2,10 +2,16 @@ import { useEffect } from 'react'; import { useErrorBoundary } from 'react-error-boundary'; import { useGetComponentsQuery } from '@/services/componentAndLayout'; import List from '@/components/list/List'; -import { toArray } from 'lodash'; const ComponentList = () => { - const { data: components, error, isLoading } = useGetComponentsQuery(); + const { + data: components, + error, + isLoading, + } = useGetComponentsQuery({ + mode: 'exclude', + libraries: ['dynamic_components'], + }); const { showBoundary } = useErrorBoundary(); useEffect(() => { @@ -14,13 +20,9 @@ const ComponentList = () => { } }, [error, showBoundary]); - const sortedComponents = components - ? toArray(components).sort((a, b) => a.name.localeCompare(b.name)) - : {}; - return ( <List - items={sortedComponents} + items={components} isLoading={isLoading} type="component" label="Components" diff --git a/ui/src/components/list/ExposedJsComponent.tsx b/ui/src/components/list/ExposedJsComponent.tsx index c84b646fa6a7fdd629b8a911090b5b53a290160a..71cc4f4fcc74619775be853a1d58abce5907981e 100644 --- a/ui/src/components/list/ExposedJsComponent.tsx +++ b/ui/src/components/list/ExposedJsComponent.tsx @@ -1,4 +1,3 @@ -import type { ComponentListItem } from '@/components/list/List'; import SidebarNode from '@/components/sidebar/SidebarNode'; import type React from 'react'; import { useEffect } from 'react'; @@ -17,6 +16,7 @@ import type { CodeComponentSerialized } from '@/types/CodeComponent'; import { selectLayout } from '@/features/layout/layoutModelSlice'; import { componentExistsInLayout } from '@/features/layout/layoutUtils'; import { useErrorBoundary } from 'react-error-boundary'; +import type { JSComponent } from '@/types/Component'; import { useNavigate } from 'react-router-dom'; import useXbParams from '@/hooks/useXbParams'; @@ -27,9 +27,7 @@ function removeJsPrefix(input: string): string { return input; } -const ExposedJsComponent: React.FC<{ component: ComponentListItem }> = ( - props, -) => { +const ExposedJsComponent: React.FC<{ component: JSComponent }> = (props) => { const dispatch = useAppDispatch(); const { component } = props; const machineName = removeJsPrefix(component.id); diff --git a/ui/src/components/list/List.tsx b/ui/src/components/list/List.tsx index 8f00d905708e59b5e659269ec70d1983ce2251a4..3e5835cb4e4ef12801e8989bac0d3beb8924d101 100644 --- a/ui/src/components/list/List.tsx +++ b/ui/src/components/list/List.tsx @@ -1,4 +1,5 @@ import type React from 'react'; +import { useMemo } from 'react'; import { useEffect, useRef, useCallback } from 'react'; import styles from './List.module.css'; import { @@ -11,42 +12,20 @@ import { useAppDispatch, useAppSelector } from '@/app/hooks'; import { Box, Flex, Spinner } from '@radix-ui/themes'; import clsx from 'clsx'; import ListItem from '@/components/list/ListItem'; -import type { LayoutModelPiece } from '@/features/layout/layoutModelSlice'; import { isDropTargetBetweenTwoElementsOfSameComponent, isDropTargetInSlotAllowedByEdgeDistance, } from '@/features/sortable/sortableUtils'; -import type { TransformConfig } from '@/utils/transforms'; +import type { ComponentsList } from '@/types/Component'; +import type { SectionsList } from '@/types/Section'; export interface ListProps { - items: ListData | undefined; + items: ComponentsList | SectionsList | undefined; isLoading: boolean; type: 'component' | 'section'; label: string; } -export interface ListItemBase { - id: string; - name: string; - metadata: Record<string, any>; - default_markup: string; - css: string; - js_header: string; - js_footer: string; -} -export interface ComponentListItem extends ListItemBase { - field_data: Record<string, any>; - source: string; - transforms: TransformConfig; -} -export interface SectionListItem extends ListItemBase { - layoutModel: LayoutModelPiece; -} - -interface ListData { - [key: string]: ComponentListItem | SectionListItem; -} - const List: React.FC<ListProps> = (props) => { const { items, isLoading, type } = props; const dispatch = useAppDispatch(); @@ -54,6 +33,15 @@ const List: React.FC<ListProps> = (props) => { const listElRef = useRef<HTMLDivElement>(null); const { isDragging } = useAppSelector(selectDragging); + // Sort items and convert to array. + const sortedItems = useMemo(() => { + return items + ? Object.entries(items).sort(([, a], [, b]) => + a.name.localeCompare(b.name), + ) + : []; + }, [items]); + const handleDragStart = useCallback(() => { dispatch(setListDragging(true)); }, [dispatch]); @@ -124,8 +112,8 @@ const List: React.FC<ListProps> = (props) => { <Box className={isDragging ? 'list-dragging' : ''}> <Spinner loading={isLoading}> <Flex direction="column" width="100%" ref={listElRef}> - {items && - Object.entries(items).map(([id, item]) => ( + {sortedItems && + sortedItems.map(([id, item]) => ( <ListItem item={item} key={id} type={type} /> ))} </Flex> diff --git a/ui/src/components/list/ListItem.tsx b/ui/src/components/list/ListItem.tsx index 9e7f5d9d962b0ff3f69ee93073572950b75d1acb..a4d998a0d250d94391a17d8796ef5577925f34fe 100644 --- a/ui/src/components/list/ListItem.tsx +++ b/ui/src/components/list/ListItem.tsx @@ -1,8 +1,6 @@ import type React from 'react'; -import type { - ComponentListItem, - SectionListItem, -} from '@/components/list/List'; +import type { XBComponent, JSComponent } from '@/types/Component'; +import type { Section } from '@/types/Section'; import { useState } from 'react'; import clsx from 'clsx'; import styles from '@/components/list/List.module.css'; @@ -24,14 +22,14 @@ import useXbParams from '@/hooks/useXbParams'; import { DEFAULT_REGION } from '@/features/ui/uiSlice'; const ListItem: React.FC<{ - item: ComponentListItem | SectionListItem; + item: XBComponent | Section; type: 'component' | 'section'; }> = (props) => { const { item, type } = props; const dispatch = useAppDispatch(); const layout = useAppSelector(selectLayout); const [previewingComponent, setPreviewingComponent] = useState< - ComponentListItem | SectionListItem + XBComponent | Section >(); const { componentId: selectedComponent, @@ -55,7 +53,7 @@ const ListItem: React.FC<{ addNewComponentToLayout( { to: newPath, - component: item as ComponentListItem, + component: item as XBComponent, }, setSelectedComponent, ), @@ -65,7 +63,7 @@ const ListItem: React.FC<{ addNewSectionToLayout( { to: newPath, - layoutModel: (item as SectionListItem).layoutModel, + layoutModel: (item as Section).layoutModel, }, setSelectedComponent, ), @@ -74,23 +72,22 @@ const ListItem: React.FC<{ } }; - const handleMouseEnter = (component: ComponentListItem | SectionListItem) => { + const handleMouseEnter = (component: XBComponent | Section) => { setPreviewingComponent(component); }; const renderItem = () => { if ( type === 'component' && - (item as ComponentListItem).source === 'Code component' + (item as JSComponent).source === 'Code component' ) { - return <ExposedJsComponent component={item as ComponentListItem} />; + return <ExposedJsComponent component={item as JSComponent} />; } return ( <SidebarNode title={item.name} variant={ - type === 'component' && - (item as ComponentListItem).source === 'Blocks' + type === 'component' && (item as XBComponent).source === 'Blocks' ? 'blockComponent' : type } diff --git a/ui/src/components/topbar/Topbar.tsx b/ui/src/components/topbar/Topbar.tsx index f80ad0a0ed39686f96bcfbcfdc6b64b4925c0df8..85064b74e64fe43ba10ad673f64b2a6d065cbf50 100644 --- a/ui/src/components/topbar/Topbar.tsx +++ b/ui/src/components/topbar/Topbar.tsx @@ -5,7 +5,6 @@ import { Flex, Grid, SegmentedControl, - Text, Tooltip, } from '@radix-ui/themes'; import Panel from '@/components/Panel'; @@ -19,10 +18,9 @@ import clsx from 'clsx'; import UnpublishedChanges from '@/components/review/UnpublishedChanges'; import PageInfo from '../pageInfo/PageInfo'; import ExtensionsList from '@/components/extensions/ExtensionsList'; -import type React from 'react'; -import { handleNonWorkingBtn } from '@/utils/function-utils'; import TopbarPopover from '@/components/topbar/menu/TopbarPopover'; import topBarStyles from '@/components/topbar/Topbar.module.css'; +import DynamicComponents from '@/components/dynamicComponents/DynamicComponents'; const PREVIOUS_URL_STORAGE_KEY = 'XBPreviousURL'; @@ -94,23 +92,19 @@ const Topbar = () => { <ExtensionsList /> </TopbarPopover> <TopbarPopover - tooltip="CMS" + tooltip="Dynamic components" trigger={ <Button variant="ghost" color="gray" size="2" className={clsx(styles.topBarButton)} - onClick={(e: React.MouseEvent<HTMLButtonElement>) => { - e.preventDefault(); - handleNonWorkingBtn(); - }} > <CMSIcon height="24" width="auto" /> </Button> } > - <Text>Not yet supported</Text> + <DynamicComponents /> </TopbarPopover> </Flex> <Flex align="center" justify="center" gap="2"> diff --git a/ui/src/components/topbar/menu/TopbarPopover.tsx b/ui/src/components/topbar/menu/TopbarPopover.tsx index 952ea770c7400dd1e935146b2f58d93632e6bfb4..10e337461ba345a16a168edaf2602a1187e477ce 100644 --- a/ui/src/components/topbar/menu/TopbarPopover.tsx +++ b/ui/src/components/topbar/menu/TopbarPopover.tsx @@ -22,7 +22,9 @@ const TopbarPopover: React.FC<TopbarPopoverProps> = ({ <Popover.Trigger>{trigger}</Popover.Trigger> </Tooltip> <Popover.Content asChild width="100vw" maxWidth="400px"> - <Panel className={clsx(styles.content, 'xb-app')}>{children}</Panel> + <Panel className={clsx(styles.content, 'xb-app')} maxHeight="50vh"> + {children} + </Panel> </Popover.Content> </Popover.Root> ); diff --git a/ui/src/features/layout/layoutModelSlice.ts b/ui/src/features/layout/layoutModelSlice.ts index 9a211faa227497ff967ef83c767d8f5b7a836388..f810216053b10ee50ce7fcb1ea6da1fc8b14c99c 100644 --- a/ui/src/features/layout/layoutModelSlice.ts +++ b/ui/src/features/layout/layoutModelSlice.ts @@ -1,6 +1,6 @@ // cspell:ignore uuidv import type { AppDispatch, RootState } from '@/app/store'; -import type { Component } from '@/types/Component'; +import type { XBComponent } from '@/types/Component'; import { componentHasFieldData } from '@/types/Component'; import type { UUID } from '@/types/UUID'; import type { PayloadAction } from '@reduxjs/toolkit'; @@ -112,7 +112,7 @@ type InsertMultipleNodesPayload = { type AddNewNodePayload = { to: number[]; - component: Component; + component: XBComponent; }; type AddNewSectionPayload = { @@ -403,7 +403,7 @@ export const addNewComponentToLayout = (dispatch: AppDispatch) => { const { to, component } = payload; // Populate the model data with the default values - const buildInitialData = (component: Component): ComponentModel => { + const buildInitialData = (component: XBComponent): ComponentModel => { if (componentHasFieldData(component)) { const initialData: EvaluatedComponentModel = { name: component.name, diff --git a/ui/src/hooks/useGetComponentName.ts b/ui/src/hooks/useGetComponentName.ts index c369e28a8cf86f4458b62fde2aeafddff469b395..df82a887f3ad0f235da417db3fa6d4d99f990308 100644 --- a/ui/src/hooks/useGetComponentName.ts +++ b/ui/src/hooks/useGetComponentName.ts @@ -4,7 +4,7 @@ import type { LayoutChildNode, LayoutNode, } from '@/features/layout/layoutModelSlice'; -import type { Component } from '@/types/Component'; +import type { XBComponent } from '@/types/Component'; import { componentHasFieldData } from '@/types/Component'; const useGetComponentName = ( @@ -15,7 +15,7 @@ const useGetComponentName = ( const findPresentationSlotName = ( slotName: string | undefined, - parentComponent: Component, + parentComponent: XBComponent, ) => { if ( slotName && diff --git a/ui/src/hooks/usePreviewSortable.ts b/ui/src/hooks/usePreviewSortable.ts index d458f34fb9febb56e7a57a9487f231e26091f567..fea63b94143fc9b829dedbc3fa70ae34bd0a6620 100644 --- a/ui/src/hooks/usePreviewSortable.ts +++ b/ui/src/hooks/usePreviewSortable.ts @@ -115,19 +115,23 @@ function usePreviewSortable( ); } } else if (type === 'section') { - // Adding a section template. - ev.item.innerHTML = '<p>Loading section...</p>'; - dispatch( - addNewSectionToLayout( - { - to: newPath, - layoutModel: - sectionsRef?.current?.[ev.clone.dataset.xbComponentId] - .layoutModel, - }, - setSelectedComponent, - ), - ); + if ( + sectionsRef?.current?.[ev.clone.dataset.xbComponentId].layoutModel + ) { + // Adding a section template. + ev.item.innerHTML = '<p>Loading section...</p>'; + dispatch( + addNewSectionToLayout( + { + to: newPath, + layoutModel: + sectionsRef.current[ev.clone.dataset.xbComponentId] + .layoutModel, + }, + setSelectedComponent, + ), + ); + } } } } diff --git a/ui/src/services/componentAndLayout.ts b/ui/src/services/componentAndLayout.ts index 247d4f092b31d142a52e25da0fc75d4ef7db6fe1..f0a5abb302fe574c0b107dc1b81f764a532f7327 100644 --- a/ui/src/services/componentAndLayout.ts +++ b/ui/src/services/componentAndLayout.ts @@ -1,18 +1,40 @@ import { createApi } from '@reduxjs/toolkit/query/react'; import { baseQuery } from '@/services/baseQuery'; import type { CodeComponentSerialized } from '@/types/CodeComponent'; -import type { ComponentsList } from '@/types/Component'; +import type { ComponentsList, libraryTypes } from '@/types/Component'; import type { RootLayoutModel } from '@/features/layout/layoutModelSlice'; import { setPageData } from '@/features/pageData/pageDataSlice'; +type getComponentsQueryOptions = { + libraries: libraryTypes[]; + mode: 'include' | 'exclude'; +}; + export const componentAndLayoutApi = createApi({ reducerPath: 'componentAndLayoutApi', baseQuery, tagTypes: ['Components', 'CodeComponents', 'CodeComponentAutoSave', 'Layout'], endpoints: (builder) => ({ - getComponents: builder.query<ComponentsList, void>({ + getComponents: builder.query< + ComponentsList, + getComponentsQueryOptions | void + >({ query: () => `xb/api/config/component`, providesTags: () => [{ type: 'Components', id: 'LIST' }], + transformResponse: (response: ComponentsList, meta, arg) => { + if (!arg || !Array.isArray(arg.libraries)) { + // If no filter is provided, return all components. + return response; + } + + // Filter the response based on the include/exclude and the list of library types passed. + return Object.fromEntries( + Object.entries(response).filter(([, value]) => { + const isIncluded = arg.libraries.includes(value.library); + return arg.mode === 'include' ? isIncluded : !isIncluded; + }), + ); + }, }), getLayoutById: builder.query< RootLayoutModel & { diff --git a/ui/src/services/sections.ts b/ui/src/services/sections.ts index cbd07afa3ec8005fa973f8c38af7c9996afc704b..707f271c80840f608c0c6cacb576e251a807af3d 100644 --- a/ui/src/services/sections.ts +++ b/ui/src/services/sections.ts @@ -2,6 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react'; import { baseQuery } from '@/services/baseQuery'; import type { LayoutModelPiece } from '@/features/layout/layoutModelSlice'; +import type { SectionsList } from '@/types/Section'; interface SaveSectionData extends LayoutModelPiece { name: string; @@ -13,7 +14,7 @@ export const sectionApi = createApi({ baseQuery, tagTypes: ['Sections'], endpoints: (builder) => ({ - getSections: builder.query<any, void>({ + getSections: builder.query<SectionsList, void>({ query: () => `/xb/api/config/pattern`, providesTags: () => [{ type: 'Sections', id: 'LIST' }], }), diff --git a/ui/src/types/Component.ts b/ui/src/types/Component.ts index 5693cff6d04da342cab33b063818f5278a1fa314..9680d4e386b763d3b8139864e6e46aa2dc7a06a7 100644 --- a/ui/src/types/Component.ts +++ b/ui/src/types/Component.ts @@ -23,18 +23,40 @@ export interface FieldDataItem { [x: string | number | symbol]: unknown; } -export interface SimpleComponent { - name: string; +interface BaseComponent { id: string; + name: string; + library: string; + category: string; + source: string; default_markup: string; css: string; js_header: string; js_footer: string; - // The source plugin label. - source: string; } -export interface PropSourceComponent extends SimpleComponent { +export type libraryTypes = + | 'dynamic_components' + | 'primary_components' + | 'extension_components' + | 'elements'; + +// For now, these are only Blocks. Later, it will be more. +export interface DynamicComponent extends BaseComponent { + library: 'dynamic_components'; +} + +// JSComponent Interface +export interface JSComponent extends BaseComponent { + library: 'primary_components'; + source: 'Code component'; + dynamic_prop_source_candidates: any[]; + transforms: any[]; +} + +// PropSourceComponent Interface +export interface PropSourceComponent extends BaseComponent { + library: 'elements' | 'extension_components'; // @todo rename this to propSources - https://www.drupal.org/project/experience_builder/issues/3504421 field_data: FieldData; metadata: { @@ -46,20 +68,21 @@ export interface PropSourceComponent extends SimpleComponent { }; [key: string]: any; }; - source: string; + dynamic_prop_source_candidates: Record<string, any>; transforms: TransformConfig; } +// Union type for any component +export type XBComponent = DynamicComponent | JSComponent | PropSourceComponent; -export type Component = PropSourceComponent | SimpleComponent; - +// ComponentsList representing the API response export interface ComponentsList { - [key: string]: Component; + [key: string]: XBComponent; } /** * Type predicate. * - * @param {Component | undefined} component + * @param {XBComponent | undefined} component * Component to test. * * @return boolean @@ -68,7 +91,7 @@ export interface ComponentsList { * @todo rename this to componentHasPropSources in https://www.drupal.org/project/experience_builder/issues/3504421 */ export const componentHasFieldData = ( - component: Component | undefined, + component: XBComponent | undefined, ): component is PropSourceComponent => { return component !== undefined && 'field_data' in component; }; diff --git a/ui/src/types/Section.ts b/ui/src/types/Section.ts index 250d57d392217a36a6682f1e424c2036ad88c10d..f26fb258d66a198493adc5b6d0a9d9411ef6e437 100644 --- a/ui/src/types/Section.ts +++ b/ui/src/types/Section.ts @@ -1,14 +1,16 @@ -import type { LayoutModelSliceState } from '@/features/layout/layoutModelSlice'; -import type { Component } from '@/types/Component'; +import type { LayoutModelPiece } from '@/features/layout/layoutModelSlice'; export interface Section { + layoutModel: LayoutModelPiece; name: string; id: string; default_markup: string; - components: Component[]; - layoutModel: LayoutModelSliceState; + css: string; + js_header: string; + js_footer: string; } +// Type for the API response, an object keyed by section ID export interface SectionsList { [key: string]: Section; } diff --git a/ui/tests/e2e/extension.cy.js b/ui/tests/e2e/extension.cy.js index 5a26d4cccb84486a960fdfff6f5931131e71e58d..1959ca20f9e4e5fd25edb740328bfc9d39195f7e 100644 --- a/ui/tests/e2e/extension.cy.js +++ b/ui/tests/e2e/extension.cy.js @@ -15,37 +15,42 @@ describe('extending experience builder', () => { it('Insert, focus, delete a component', () => { cy.loadURLandWaitForXBLoaded(); cy.openLibraryPanel(); - const availableComponents = []; + cy.get('.primaryPanelContent [data-state="open"]').contains('Components'); // Get the components list from the sidebar so it can be compared to the // component select dropdown provided by the extension. cy.get('.primaryPanelContent [data-state="open"] [data-xb-name]').then( ($components) => { + const availableComponents = []; + $components.each((index, item) => { - availableComponents.push(item.textContent); + availableComponents.push(item.textContent.trim()); + }); + + cy.findByTestId('ex-select-component').then(($select) => { + const extensionComponents = []; + // Get all the items with values in the extension component list, which + // will be compared to the component list from the XB UI. + $select.find('option').each((index, item) => { + if (item.value) { + extensionComponents.push(item.textContent.trim()); + } + }); + + // Check if every available component is included in the extension components + const allAvailableComponentsExist = availableComponents.every( + (component) => extensionComponents.includes(component), + ); + + expect( + allAvailableComponentsExist, + 'All library components exist in the extension component dropdown', + ).to.be.true; }); }, ); - cy.findByTestId('ex-select-component').then(($select) => { - const extensionComponents = []; - // Get all the items with values in the extension component list, which - // will be compared to the component list from the XB UI. - $select.find('option').each((index, item) => { - if (item.value) { - extensionComponents.push(item.textContent); - } - }); - - // Comparing these two arrays as strings works reliably was opposed to - // deep equal. - expect( - extensionComponents.sort().join(), - 'The extension provides a components dropdown with every available component in the XB UI', - ).to.equal(availableComponents.sort().join()); - }); - cy.log( 'Confirm that an extension can select an item in the layout, focus it, then delete it', ); diff --git a/ui/tests/e2e/primary-panel.cy.js b/ui/tests/e2e/primary-panel.cy.js index 87d88c589c1372531b74acd3a3f4710cdeca5436..430ff63ae1994d9a1c6ae48a480af7128ec2b786 100644 --- a/ui/tests/e2e/primary-panel.cy.js +++ b/ui/tests/e2e/primary-panel.cy.js @@ -20,7 +20,11 @@ describe('Primary panel', () => { .reduce((acc, _, index) => { const paddedIndex = String(index + 1).padStart(2, '0'); const id = `experience_builder:component_${paddedIndex}`; - acc[id] = { id, name: `Component ${paddedIndex}` }; + acc[id] = { + id, + name: `Component ${paddedIndex}`, + library: 'elements', + }; return acc; }, {}), }).as('getComponents');