Skip to content

Commit

Permalink
Add initial experimental custom components support
Browse files Browse the repository at this point in the history
Signed-off-by: Zvonimir Fras <[email protected]>
  • Loading branch information
zvonimirfras committed Oct 12, 2023
1 parent ff5527c commit a34899f
Show file tree
Hide file tree
Showing 10 changed files with 322 additions and 15 deletions.
55 changes: 55 additions & 0 deletions src/components/custom-components-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { useContext, useState } from 'react';
import { Modal } from '@carbon/react';
import Editor from '@monaco-editor/react';
import { GlobalStateContext, ModalContext } from '../context';

export const CustomComponentsModal = () => {
const { customComponentsModal, hideCustomComponentsModal } = useContext(ModalContext);
const { customComponentsCollections, setCustomComponentsCollections } = useContext(GlobalStateContext);
const [jsonParseError, setJsonParseError] = useState('');
const [model, _setModel] = useState(JSON.stringify(customComponentsCollections ? customComponentsCollections[0] : {}, null, '\t'));

const setModel = (modelString: string) => {
_setModel(modelString);
try {
if (modelString) {
// TODO set exact modelCollection based on name instead
setCustomComponentsCollections([JSON.parse(modelString)]);
}

setJsonParseError('');
} catch (e) {
setJsonParseError((e as any).toString());
}
};

const handleEditorChange = (value: any, _event: any) => {
setModel(value);
};

return <Modal
size='lg'
open={customComponentsModal.isVisible}
onRequestClose={hideCustomComponentsModal}
modalHeading='Custom components (Experimental)'
primaryButtonText='Done'
onRequestSubmit={() => {
hideCustomComponentsModal();
}}>
{
jsonParseError
&& <>
Not saved until the error is corrected:
<code style={{ color: '#a00', marginBottom: '10pt', width: '100%' }}>
<pre>{jsonParseError}</pre>
</code>
</>
}
<Editor
height='calc(100vh - 32px)'
language='json'
value={model}
onChange={handleEditorChange}
/>
</Modal>;
};
119 changes: 113 additions & 6 deletions src/components/fragment.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import React, { useContext, useRef, useState } from 'react';
import React, {
useContext,
useEffect,
useRef,
useState
} from 'react';
import { SkeletonPlaceholder } from '@carbon/react';
import { Add, DropPhoto } from '@carbon/react/icons';
import './fragment-preview.scss';
import { css, cx } from 'emotion';
import { allComponents, ComponentInfoRenderProps } from '../sdk/src/fragment-components';
import parse, { attributesToProps, domToReact } from 'html-react-parser';
import { AComponent, allComponents, ComponentInfoRenderProps } from '../sdk/src/fragment-components';
import { getFragmentsFromLocalStorage } from '../utils/fragment-tools';
import { GlobalStateContext } from '../context';
import { getAllFragmentStyleClasses } from '../ui-fragment/src/utils';
import { getDropIndex, stateWithoutComponent, updateComponentCounter, updatedState } from '../sdk/src/tools';
import { getAllFragmentStyleClasses, styleObjectToString } from '../ui-fragment/src/utils';
import { getDropIndex, getUsedCollectionsStyleUrls, stateWithoutComponent, updateComponentCounter, updatedState } from '../sdk/src/tools';
import { throttle } from 'lodash';
import axios from 'axios';

const canvas = css`
border: 2px solid #d8d8d8;
Expand Down Expand Up @@ -68,12 +75,55 @@ const allowDrop = (event: any) => {
event.preventDefault();
};

const fetchCSS = async (urls: string[]) => {
try {
const responses = await Promise.all(
urls.map((url) => axios.get(url, { responseType: 'text' }))
);

const cssContent = responses.map((response) => response.data + '\n').join();

return css`
${cssContent}
`;
} catch (error) {
console.error(error);
}
};

export const Fragment = ({ fragment, setFragment, outline }: any) => {
const globalState = useContext(GlobalStateContext);
const [showDragOverIndicator, setShowDragOverIndicator] = useState(false);
const [customComponentsStyles, setCustomComponentsStyles] = useState(css``);
const [customComponentsStylesUrls, _setCustomComponentsStylesUrls] = useState<string[]>([]);
const containerRef = useRef(null as any);
const holderRef = useRef(null as any);

const setCustomComponentsStylesUrls = (ccsUrls: string[]) => {
// comparing by reference first avoids stringifying in most situations when update isn't needed
if (
ccsUrls !== customComponentsStylesUrls
&& JSON.stringify(ccsUrls) !== JSON.stringify(customComponentsStylesUrls)
) {
_setCustomComponentsStylesUrls(ccsUrls);
}
};

// fetch the css from requested urls
useEffect(() => {
if (!customComponentsStylesUrls?.length) {
return;
}
fetchCSS(customComponentsStylesUrls).then((value) => setCustomComponentsStyles(css`${value || ''}`));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [customComponentsStylesUrls]);

// update requested urls
useEffect(() => {
setCustomComponentsStylesUrls(getUsedCollectionsStyleUrls(globalState.customComponentsCollections, fragment.data));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fragment.data, globalState.customComponentsCollections]);

const resize = throttle((e: any) => {
const rect = containerRef.current.getBoundingClientRect();
setFragment({
Expand Down Expand Up @@ -196,6 +246,63 @@ export const Fragment = ({ fragment, setFragment, outline }: any) => {
}
}

// by the time we're here it wasn't any of the built-in components
// ///////////////////////////////////////////////
// RENDERING CUSTOM COMPONENTS //
// ///////////////////////////////////////////////
if (componentObj.componentsCollection) {
// our component belongs to one of the custom components collections
const customComponentsCollection =
globalState.customComponentsCollections?.find((ccc: any) => ccc.name === componentObj.componentsCollection);
if (customComponentsCollection) {
const customComponent = customComponentsCollection.components.find((cc: any) => cc.type === componentObj.type);

if (customComponent?.htmlPreview) {
// replace the inputs placeholders with values before rendering
let htmlPreview = customComponent.htmlPreview;
Object.keys(customComponent.inputs).forEach((input: string) => {
htmlPreview = htmlPreview.split(`{{${input}}}`).join(componentObj[input]);
});

const options = {
replace: (domNode: any) => {
// check if the domNode is the root element
if (domNode.parent === null) {
const props = attributesToProps(domNode.attribs);

// add layout and user styles classes
props.className = `${props.className || ''} ${cx(
componentObj.cssClasses?.map((cc: any) => cc.id).join(' '),
// getting very div div specific to override artificial specificity
// brought by importing external css into an emotion style.
// One div matches specificity and is ok for new edits, but onload
// external css takes precedence, hence 2
css`div div & { ${styleObjectToString(componentObj.style)} }`
)}`;

return React.createElement(domNode.name, props, domToReact(domNode.children));
}

return domNode;
}
};

const html = parse(htmlPreview, options);

return <AComponent
componentObj={componentObj}
select={() => select(componentObj)}
remove={() => remove(componentObj)}
selected={fragment.selectedComponentId === componentObj.id}
outline={outline}
fragment={fragment}
setFragment={setFragment}>
{html}
</AComponent>;
}
}
}

if (componentObj.items) {
return componentObj.items.map((item: any) => renderComponents(item, outline));
}
Expand All @@ -213,7 +320,7 @@ export const Fragment = ({ fragment, setFragment, outline }: any) => {
ref={containerRef}
className={cx(
canvas,
styles,
customComponentsStyles,
css`width: ${fragment.width || '800'}px; height: ${fragment.height || '600'}px`
)}
style={{
Expand All @@ -231,7 +338,7 @@ export const Fragment = ({ fragment, setFragment, outline }: any) => {
}}
onDragOver={allowDrop}
onDrop={drop}>
<div ref={holderRef} className={`${fragment.cssClasses ? fragment.cssClasses.map((cc: any) => cc.id).join(' ') : ''}`}>
<div ref={holderRef} className={cx(styles, `${fragment.cssClasses ? fragment.cssClasses.map((cc: any) => cc.id).join(' ') : ''}`)}>
{
!fragment.data?.items?.length && <div className={centerStyle} onClick={addGrid}>
<div>
Expand Down
16 changes: 14 additions & 2 deletions src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
Header as ShellHeader,
SkipToContent,
Switcher,
SwitcherItem
SwitcherItem,
Tooltip
} from '@carbon/react';
import {
ChatLaunch,
Expand All @@ -24,7 +25,8 @@ import {
Keyboard,
LogoGithub,
UserAvatar,
Help
Help,
Warning
} from '@carbon/react/icons';
import { css } from 'emotion';
import { NavigateFunction, useNavigate } from 'react-router-dom';
Expand Down Expand Up @@ -254,6 +256,16 @@ export const Header = ({
Log in with GitHub
</SwitcherItem>
}
<SwitcherItem
aria-label='custom components'
onClick={() => modalContext.showCustomComponentsModal()}>
Custom components
<Tooltip align='bottom' label='Experimental functionality.'>
<button type='button' className={css`border: none; background: inherit;`}>
<Warning />
</button>
</Tooltip>
</SwitcherItem>
</Switcher>
</HeaderPanel>
</ShellHeader>
Expand Down
16 changes: 15 additions & 1 deletion src/context/global-state-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import React, {
} from 'react';
import assign from 'lodash/assign';
import { getFragmentHelpers } from './fragments-context-helper';
import { getFragmentsFromLocalStorage } from '../utils/fragment-tools';
import {
getCustomComponentsCollectionsFromLocalStorage,
getFragmentsFromLocalStorage
} from '../utils/fragment-tools';
import { expandJsonToState } from '../ui-fragment/src/utils';
import { getFragmentJsonExport as getFragmentJsonExport_ } from '../sdk/src/tools';
import { CURRENT_MODEL_VERSION, updateModelInPlace } from '../utils/model-converter';
Expand Down Expand Up @@ -44,6 +47,7 @@ const GlobalStateContextProvider = ({ children }: any) => {
const [actionHistoryIndex, setActionHistoryIndex] = useState(-1);

const [styleClasses, _setStyleClasses] = useState(JSON.parse(localStorage.getItem('globalStyleClasses') as string || '[]') as any[]);
const [customComponentsCollections, _setCustomComponentsCollections] = useState<any[]>(getCustomComponentsCollectionsFromLocalStorage());
const [settings, _setSettings] = useState(JSON.parse(localStorage.getItem('globalSettings') as string || '{}') as any);

const [githubToken, _setGithubToken] = useState(localStorage.getItem('githubToken') as string || '');
Expand Down Expand Up @@ -73,6 +77,12 @@ const GlobalStateContextProvider = ({ children }: any) => {
}
};

const setCustomComponentsCollections = (ccc: any) => {
const cccString = JSON.stringify(ccc);
localStorage.setItem('customComponentsCollections', cccString);
_setCustomComponentsCollections(ccc);
};

const setGithubToken = (token: string) => {
localStorage.setItem('githubToken', token);
_setGithubToken(token);
Expand Down Expand Up @@ -193,6 +203,10 @@ const GlobalStateContextProvider = ({ children }: any) => {
styleClasses,
setStyleClasses,

// CUSTOM COMPONENTS COLLECTIONS
customComponentsCollections,
setCustomComponentsCollections,

// SETTINGS
settings,
setSettings,
Expand Down
25 changes: 24 additions & 1 deletion src/context/modal-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,25 @@ const ModalContextProvider = ({ children }: any) => {
});
};

// /////////////////////////////
// Logout github modal //
// /////////////////////////////
const [customComponentsModalState, setCustomComponentsModalState] = useState({
isVisible: false
} as any);

const showCustomComponentsModal = () => {
setCustomComponentsModalState({
isVisible: true
});
};

const hideCustomComponentsModal = () => {
setCustomComponentsModalState({
isVisible: false
});
};

return (
<ModalContext.Provider value={{
modal: modalState,
Expand Down Expand Up @@ -180,7 +199,11 @@ const ModalContextProvider = ({ children }: any) => {

logoutGithubModal: logoutGithubModalState,
showLogoutGithubModal,
hideLogoutGithubModal
hideLogoutGithubModal,

customComponentsModal: customComponentsModalState,
showCustomComponentsModal,
hideCustomComponentsModal
}}>
{children}
</ModalContext.Provider>
Expand Down
2 changes: 2 additions & 0 deletions src/routes/edit/all-modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ExportModal } from './share-options/exports/export-modal';
import { FragmentPreviewModal } from './fragment-preview-modal';
import { LoginGithubModal } from '../../components/login-github-modal';
import { LogoutGithubModal } from '../../components/logout-github-modal';
import { CustomComponentsModal } from '../../components/custom-components-modal';

// eslint-disable-next-line react/prop-types
export const AllModals = () => {
Expand Down Expand Up @@ -37,5 +38,6 @@ export const AllModals = () => {
}
<LoginGithubModal />
<LogoutGithubModal />
<CustomComponentsModal />
</>;
};
29 changes: 28 additions & 1 deletion src/routes/edit/elements-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ export const ElementsPane = ({ isActive }: any) => {
const mouseYStart = useRef(0);
const heightStart = useRef(0);
const [fragment, setFragment] = useFragment();
const { fragments, settings, setSettings, styleClasses } = useContext(GlobalStateContext);
const {
fragments,
settings,
setSettings,
styleClasses,
customComponentsCollections
} = useContext(GlobalStateContext);

const isLayoutWidgetOpen = settings.layoutWidget?.isAccordionOpen === undefined
? true // open by default
Expand Down Expand Up @@ -133,6 +139,27 @@ export const ElementsPane = ({ isActive }: any) => {
</ElementTile>)
}
</div>
{
customComponentsCollections && customComponentsCollections.length > 0
&& customComponentsCollections.map((customComponentsCollection: any) => <>
<h4>{customComponentsCollection.name}</h4>
<div className={elementTileListStyleMicroLayouts}>
{
customComponentsCollection.components?.map((component: any) =>
<ElementTile
componentObj={{
...component.defaultInputs,
type: component.type,
componentsCollection: customComponentsCollection.name
}}
key={component.type}>
Preview
<span className='title'>{component.type}</span>
</ElementTile>)
}
</div>
</>)
}
{
visibleMicroLayouts && visibleMicroLayouts.length > 0 && <>
<h4>Micro layouts</h4>
Expand Down
Loading

0 comments on commit a34899f

Please sign in to comment.