Example
Last updated
Last updated
import { Button } from "@chakra-ui/button";
import { useDisclosure, useOutsideClick } from "@chakra-ui/hooks";
import Icon from "@chakra-ui/icon";
import {
Box,
Center,
Flex,
Spacer,
Text,
Image,
VStack,
FormControl,
FormLabel,
Input,
Spinner,
Link,
} from "@chakra-ui/react";
import {
Modal,
ModalHeader,
ModalBody,
ModalFooter,
ModalContent,
ModalOverlay,
} from "@chakra-ui/modal";
import { Popover, PopoverContent, PopoverTrigger } from "@chakra-ui/popover";
import { Portal } from "@chakra-ui/portal";
import { css } from "@emotion/react";
import { EmotionJSX } from "@emotion/react/types/jsx-namespace";
import {
IconChevronRight,
IconClick,
IconFreetext,
IconListUl,
IconMultimedia,
IconPlus,
IconSelect,
IconTemplate,
IconDesktop,
IconCheck,
IconX,
CodeAlt,
} from "@feedloop/icon";
import produce from "immer";
import React from "react";
import { DropResult } from "react-beautiful-dnd";
import Scrollbars from "react-custom-scrollbars";
import iconsList from "../runtime/iconsList";
import {
buildComponent,
ComponentGroupType,
ComponentInstance,
ComponentProps,
getComponentList,
registerComponent,
} from "../runtime/interfaces";
import { RuntimeContext } from "../runtime/RuntimeContext";
import EditorContext from "./EditorContext";
import PageMenu from "./PageMenuItem";
import { useForm } from "react-hook-form";
import importCustomComponent from "../runtime/importCustomComponent";
const ModalLayout = (props: { isOpen: boolean; onClose: () => void }) => (
<Modal isOpen={props.isOpen} onClose={props.onClose} isCentered>
<ModalOverlay />
<ModalContent w="408px" h="448px" p="40px">
<ModalBody>
<Center>
<Image src="/assets/ilustration/dekstop.svg" alt="Desktop" />
</Center>
<Center>
<Text
css={css`
font-weight: 600;
font-size: 24px;
color: #001e4d;
`}
>
Only For Dekstop
</Text>
</Center>
<Center pt={2}>
<Text
css={css`
font-weight: normal;
font-size: 12px;
color: #6f809a;
text-align: center;
`}
>
This component only for dekstop, change to dekstop mode to use this
component
</Text>
</Center>
<Center pt={4}>
<Button
w="328px"
css={css`
background: #0065ff;
color: #fff;
`}
>
Go to Desktop Mode
</Button>
</Center>
<Center pt={2}>
<Button variant="ghost" w="328px" onClick={props.onClose}>
Cancel
</Button>
</Center>
</ModalBody>
</ModalContent>
</Modal>
);
export const bannedComponents = new Set([
"ColumnLayout2",
"ColumnLayout3",
"Gallery",
"Menu Grid",
]);
interface IAddComponentsMenu {
isOpen: boolean;
isOpenByComponent: boolean;
layoutID: string;
layoutIdx: number;
}
interface ComponentsManagerState {
componentBeingEdit: string;
addComponentMenu: IAddComponentsMenu;
}
type CustomComponentModal = "register" | "loading" | "success" | "error";
export default React.memo(function ComponentsManager() {
const pageID = RuntimeContext.useSelectState(({ control }) => control.page);
const maps = RuntimeContext.useSelectState(({ app }) => app.maps);
const runtimeActions = RuntimeContext.useActions();
const editorActions = EditorContext.useActions();
const form = useForm({
defaultValues: {
name: "",
url: "",
},
});
const [modalCustomComponent, setModalCustomComponent] =
React.useState<CustomComponentModal | null>(null);
const activeComponent = EditorContext.useSelectState(
({ activeComponent }) => activeComponent
);
const [state, setState] = React.useState<ComponentsManagerState>({
componentBeingEdit: undefined,
addComponentMenu: {
isOpen: false,
isOpenByComponent: false,
layoutID: null,
layoutIdx: null,
},
});
const page = React.useMemo(() => maps.page[pageID], [maps.page, pageID]);
const componentPresets = React.useMemo(() => getComponentList(), []);
const iconMaps = React.useMemo(
() =>
componentPresets.reduce(
(result, item) => ({
...result,
[item.id]: item.icon,
}),
{} as Record<string, string>
),
[componentPresets]
);
const components = React.useMemo(
() =>
Iterator.from(page?.components || [])
.map((ref) => maps.component[ref.component])
.filter((x) => !!x)
.toArray(),
[page?.components, maps.component]
);
const groupingComponents = React.useMemo(() => {
const groupedComponents: {
layer0: Array<ComponentInstance<ComponentProps>>;
layer1: Record<string, Array<ComponentInstance<ComponentProps>>>;
} = {
layer0: [],
layer1: {},
};
components.forEach((component) => {
if (
!!component.metadata &&
!!component.metadata.layout &&
!!component.metadata.layout.id
) {
groupedComponents.layer1[component.metadata.layout.id] = [
...(groupedComponents.layer1[component.metadata.layout.id] || []),
component,
];
} else {
groupedComponents.layer0.push(component);
}
});
return groupedComponents;
}, [components]);
React.useEffect(() => {
if (
page?.components?.find((ref) => ref.component === activeComponent) ===
undefined
) {
editorActions.setActiveComponent(page?.components?.[0]?.component);
editorActions.setEditMode("play");
}
}, [page?.components, activeComponent]);
const getNewDestination = React.useCallback(
(destinationIndex: number, draggableId: string) => {
const newDestinationFilter = groupingComponents.layer0.filter(
(comp, idx) => idx < destinationIndex
);
let newDestination = 0;
newDestinationFilter?.forEach((current) => {
if (
(current.componentID === "ColumnLayout2" ||
current.componentID === "ColumnLayout3") &&
!!groupingComponents.layer1[current.id] &&
draggableId !== current.id
) {
newDestination += groupingComponents.layer1[current.id].length + 1;
} else {
newDestination += 1;
}
});
return newDestination;
},
[groupingComponents]
);
const handleAddComponent = React.useCallback(
(presetID: string, name?: string, componentURL?: string) => {
if (presetID === "Custom Component") {
setModalCustomComponent("register");
return;
}
const compInstance = buildComponent(presetID, {
metadata: {
name,
layout:
state.addComponentMenu.isOpenByComponent &&
!!state.addComponentMenu.layoutID
? { id: state.addComponentMenu.layoutID, column: 1 }
: undefined,
componentURL,
},
});
if (!!state.addComponentMenu.isOpenByComponent) {
const layoutIdx = getNewDestination(
state.addComponentMenu.layoutIdx,
state.addComponentMenu.layoutID
);
const idx =
layoutIdx +
1 +
(groupingComponents.layer1[state.addComponentMenu.layoutID]?.length ||
0);
runtimeActions.addComponent(
compInstance,
{ page: pageID },
{ idx: idx }
);
} else {
runtimeActions.addComponent(compInstance, { page: pageID });
}
},
[pageID, state.addComponentMenu, getNewDestination, groupingComponents]
);
const handleDragEnd = React.useCallback(
(result: DropResult) => {
if (!result.destination || !page) return;
const newDestination = getNewDestination(
result.destination.index,
result.draggableId
);
const newSourceIndex: number = components.findIndex(
(comp) => comp.id === result.draggableId
);
const deletionLength =
(groupingComponents.layer1[result.draggableId]?.length || 0) + 1;
runtimeActions.swapComponent(
{ page: page.id },
newSourceIndex,
newDestination,
deletionLength
);
},
[runtimeActions, page, groupingComponents, getNewDestination, components]
);
const handleLayer1DragEnd = React.useCallback(
(result: DropResult, offset: number = 0, parentId: string) => {
if (!result.destination || !page) return;
const parentPageIndex = getNewDestination(offset, parentId);
const newDestinationIndex =
parentPageIndex + result.destination.index + 1;
const newSourceIndex = parentPageIndex + result.source.index + 1;
runtimeActions.swapComponent(
{ page: page.id },
newSourceIndex,
newDestinationIndex
);
},
[runtimeActions, page, getNewDestination]
);
const handleMoveToLayout = React.useCallback(
(sourceId: string, destinationLayoutId: string) => {
const destinationLayer0Idx = groupingComponents.layer0.findIndex(
(comp) => comp.id === destinationLayoutId
);
const layoutIndex = getNewDestination(destinationLayer0Idx, sourceId);
const sourceIndex = components.findIndex((comp) => comp.id === sourceId);
const destinationIndex =
layoutIndex +
(groupingComponents.layer1[destinationLayoutId]?.length || 0) +
(layoutIndex < sourceIndex ? 1 : 0);
runtimeActions.setComponentMetadata({ component: sourceId }, "layout", {
id: destinationLayoutId,
column: 1,
});
runtimeActions.swapComponent(
{ page: page.id },
sourceIndex,
destinationIndex
);
},
[groupingComponents, components, getNewDestination, page]
);
const handleRemoveFromLayout = React.useCallback(
(compId: string) => {
const sourceIndex = components.findIndex((comp) => comp.id === compId);
const destinationIndex = components.length;
runtimeActions.removeComponentLayoutMetadata({ component: compId });
runtimeActions.swapComponent(
{ page: page.id },
sourceIndex,
destinationIndex
);
},
[components, page]
);
const handleRenameOnBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>, compId: string) => {
runtimeActions.setComponentMetadata(
{ component: compId },
"name",
e.currentTarget.value
);
setState((state) =>
produce(state, (draft) => {
draft.componentBeingEdit = undefined;
})
);
},
[]
);
const { isOpen, onClose } = useDisclosure();
const ModalItem = ModalLayout;
const renderLayoutComponentsMenu = React.useMemo(() => {
let layer1: Record<string, EmotionJSX.Element[]> = {};
Object.keys(groupingComponents.layer1).forEach((layoutID) => {
layer1[layoutID] = [
...(layer1[layoutID] || []),
<PageMenu
onDragEnd={(result) =>
handleLayer1DragEnd(
result,
groupingComponents.layer0.findIndex(
(comp) => comp.id === layoutID
),
layoutID
)
}
iconMaps={iconMaps}
componentToEditID={state.componentBeingEdit}
components={groupingComponents.layer1[layoutID]}
onBlurRenameItem={handleRenameOnBlur}
onStateChange={setState}
onRemoveComponent={runtimeActions.removeComponent}
onSetActiveComponent={editorActions.setActiveComponent}
activeComponent={activeComponent}
onRemoveFromLayout={handleRemoveFromLayout}
/>,
];
});
return layer1;
}, [
groupingComponents,
iconMaps,
state.componentBeingEdit,
activeComponent,
handleDragEnd,
handleRenameOnBlur,
runtimeActions.removeComponent,
editorActions.setActiveComponent,
]);
const AddComponentsCallback = React.useCallback(() => {
return (
<AddComponents
isOpen={state.addComponentMenu.isOpen}
isOpenByComponent={state.addComponentMenu.isOpenByComponent}
onAddComponent={handleAddComponent}
onEventChange={(value, eventName) => {
switch (eventName) {
case "open":
setState((state) =>
produce(state, (draft) => {
draft.addComponentMenu.isOpen = value.isOpen;
draft.addComponentMenu.isOpenByComponent =
value.isOpenByComponent;
draft.addComponentMenu.layoutID = value.layoutID;
draft.addComponentMenu.layoutIdx = value.layoutIdx;
})
);
break;
case "close":
setState((state) =>
produce(state, (draft) => {
draft.addComponentMenu.isOpen = false;
})
);
break;
default:
return;
}
}}
/>
);
}, [
state.addComponentMenu.isOpenByComponent,
state.addComponentMenu.isOpen,
handleAddComponent,
]);
if (!page) return null;
return (
<Box position="relative" height="100%">
<Flex
css={css`
padding: 12px 16px;
`}
>
<Box flex={1}>
<Text fontSize="sm" fontWeight="semibold" color="#001E4D">
Components
</Text>
</Box>
<AddComponentsCallback />
</Flex>
<ModalItem isOpen={isOpen} onClose={onClose} />
<Box
position="absolute"
top="48px"
left="0"
right="0"
bottom="0"
overflowY="auto"
>
<Scrollbars>
<PageMenu
onDragEnd={handleDragEnd}
iconMaps={iconMaps}
componentToEditID={state.componentBeingEdit}
components={groupingComponents.layer0}
onBlurRenameItem={handleRenameOnBlur}
onStateChange={setState}
onRemoveComponent={runtimeActions.removeComponent}
onSetActiveComponent={editorActions.setActiveComponent}
activeComponent={activeComponent}
renderLayoutComponentsMenu={renderLayoutComponentsMenu}
layer1={groupingComponents.layer1}
onMoveToLayout={handleMoveToLayout}
/>
</Scrollbars>
</Box>
<Modal
isOpen={!!modalCustomComponent}
onClose={() => {
modalCustomComponent !== "loading" && setModalCustomComponent(null);
}}
>
<ModalOverlay />
{modalCustomComponent &&
(modalCustomComponent === "register" ? (
<ModalContent>
<ModalHeader>
<Text fontSize="2xl">Add custom component</Text>
</ModalHeader>
<ModalBody>
<VStack spacing={4}>
<Box
padding="16px"
borderRadius="4px"
backgroundColor="gray.100"
>
<Text fontSize="sm" color="gray.600">
Custom component can be used by editors to create
components with the appearance and functionality according
to their needs, if the official Qore component has not
been able to meet these needs.{" "}
<Link
href="https://docs.qore.dev/component/custom-component"
color="blue.500"
isExternal
>
Learn more
</Link>
</Text>
</Box>
<FormControl id="name">
<FormLabel>Name</FormLabel>
<Input
name="name"
ref={form.register({ required: true })}
placeholder="Component Name"
/>
</FormControl>
<FormControl id="subdomain">
<FormLabel>Component URL</FormLabel>
<Input
name="url"
ref={form.register({ required: true })}
placeholder="Component URL"
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter flex={1}>
<Button
width="100%"
mr={3}
onClick={() => setModalCustomComponent(null)}
>
Cancel
</Button>
<Button
width="100%"
colorScheme="brand"
onClick={form.handleSubmit(({ name, url }) => {
setModalCustomComponent("loading");
importCustomComponent(url)
.then(({ id, component }) => {
handleAddComponent(id, name, url);
setModalCustomComponent("success");
})
.catch((err) => {
console.error(err);
setModalCustomComponent("error");
});
})}
>
Add component
</Button>
</ModalFooter>
</ModalContent>
) : (
<ModalContent>
<ModalHeader>
<Text fontSize="2xl">Validation Component</Text>
</ModalHeader>
<ModalBody>
<Box
borderWidth="1px"
borderStyle="solid"
borderColor="gray.200"
borderRadius="md"
p={3}
>
{modalCustomComponent === "loading" ? (
<Flex alignItems="flex-start">
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="gray.200"
color="blue.500"
size="xl"
/>
<Box flex={1} marginLeft="3">
<Text fontWeight="bold">Creating Component</Text>
<Text color="gray.500" fontSize="sm">
Fetching and validating component from URL
</Text>
</Box>
</Flex>
) : modalCustomComponent === "success" ? (
<Flex alignItems="flex-start">
<Box
w={10}
h={10}
borderRadius="full"
backgroundColor="green.100"
alignItems="center"
justifyContent="center"
boxSizing="border-box"
display="flex"
>
<Icon as={IconCheck} color="green" />
</Box>
<Box flex={1} marginLeft="3">
<Text fontWeight="bold">Component created</Text>
<Text color="gray.500" fontSize="sm">
Your component has been created
</Text>
</Box>
</Flex>
) : (
<Flex alignItems="flex-start">
<Box
w={10}
h={10}
borderRadius="full"
backgroundColor="red.100"
alignItems="center"
justifyContent="center"
boxSizing="border-box"
display="flex"
>
<Icon as={IconX} color="red" />
</Box>
<Box flex={1} marginLeft="3">
<Text fontWeight="bold">Failed create component</Text>
<Text color="gray.500" fontSize="sm">
Your component URL is invalid
</Text>
</Box>
</Flex>
)}
</Box>
</ModalBody>
<ModalFooter flex={1}>
<Button
width="100%"
mr={3}
disabled={modalCustomComponent === "loading"}
onClick={() => setModalCustomComponent("register")}
>
Create again
</Button>
<Button
width="100%"
colorScheme="brand"
disabled={modalCustomComponent === "loading"}
onClick={() => setModalCustomComponent(null)}
>
Done
</Button>
</ModalFooter>
</ModalContent>
))}
</Modal>
</Box>
);
});
interface IAddComponents {
isOpen: boolean;
onEventChange: (e: Partial<IAddComponentsMenu>, eventName: string) => void;
isOpenByComponent: boolean;
onAddComponent: (componentId: string) => void;
}
interface category {
icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element;
text: ComponentGroupType;
}
const categories: Array<category> = [
{
icon: IconFreetext,
text: "text",
},
{
icon: IconMultimedia,
text: "media",
},
{
icon: IconListUl,
text: "list",
},
{
icon: IconClick,
text: "button",
},
{
icon: IconSelect,
text: "input",
},
{
icon: IconTemplate,
text: "layout",
},
{
icon: IconDesktop,
text: "data",
},
{
icon: CodeAlt,
text: "advanced",
},
];
const AddComponents = React.memo(function ({
isOpen,
isOpenByComponent,
onEventChange,
onAddComponent,
}: IAddComponents) {
const initRef = React.useRef();
const ref = React.useRef();
const componentPresets = React.useMemo(
() =>
getComponentList().concat({
legacy: true,
id: "Custom Component",
type: "record",
icon: "Atom",
group: "advanced",
defaultProps: {},
propDefinition: () => ({}),
Component: () => null,
}),
[]
);
const [activeCategory, setActiveCategory] =
React.useState<ComponentGroupType | null>(null);
useOutsideClick({
ref: ref,
handler: () => {
setActiveCategory(null);
},
});
const handlePopoverClose = React.useCallback(() => {
onEventChange(
{
isOpen: false,
},
"close"
);
}, []);
const handlePopoverOpen = React.useCallback(() => {
onEventChange(
{
isOpen: true,
isOpenByComponent: false,
layoutID: null,
layoutIdx: null,
},
"open"
);
}, []);
return (
<Box>
<Popover
placement="auto-end"
isLazy
closeOnBlur={true}
initialFocusRef={initRef}
isOpen={isOpen}
onClose={handlePopoverClose}
>
{({ onClose }) => (
<>
<PopoverTrigger>
<Box onClick={handlePopoverOpen}>
<Icon cursor="pointer" as={IconPlus} />
</Box>
</PopoverTrigger>
<Portal>
<PopoverContent
ml={2}
css={css`
border-radius: 0px;
`}
>
<Box h="93.1vh" overflow="auto" py={4}>
<Box mb={4} pl={4}>
<Text fontWeight="semibold" color="#001E4D">
All Category
</Text>
</Box>
<div ref={ref}>
{categories.map((category, index) => {
return (
<Popover
key={`${category.text}-${index}`}
isOpen={activeCategory === category.text}
placement="auto-end"
isLazy
>
<PopoverTrigger>
<Flex
my={1}
mx={2}
p={2}
cursor="pointer"
onClick={() => {
setActiveCategory(category.text);
}}
bg={
activeCategory === category.text
? "#E6F0FF"
: undefined
}
css={css`
border-radius: 8px;
&:hover {
background-color: #e6f0ff;
border-radius: 4px;
}
`}
>
<Center
p="6px"
bg={
activeCategory === category.text
? "white"
: "#E6F0FF"
}
css={css`
border-radius: 4px;
`}
>
<Icon
fontSize="24px"
as={category.icon}
color="#0065ff"
/>
</Center>
<Center pl={2}>
<Text>{category.text}</Text>
</Center>
<Spacer />
<Center>
<Icon
color="#A6B0C1"
fontSize="24px"
as={IconChevronRight}
/>
</Center>
</Flex>
</PopoverTrigger>
<PopoverContent
ml={-2}
css={css`
border-left: 1px solid white;
border-radius: 0px;
`}
>
<Box h="93.1vh" w="350" overflow="auto" py={4}>
<Scrollbars>
<Box mt={10} mx={4} bg="#F9F9FA">
{componentPresets
.filter((selected) =>
selected.group === category.text &&
selected.group &&
isOpenByComponent
? !bannedComponents.has(selected.id)
: true
)
.sort((a, b) => {
if (a.id < b.id) return -1;
else if (a.id > b.id) return 1;
else return 0;
})
.map((preset) =>
preset.group === category.text ? (
<Flex
p={2}
mb={2}
onClick={() => {
onAddComponent(preset.id);
onClose();
}}
key={preset.id}
css={css`
border: 1px solid transparent;
cursor: pointer;
background-color: white;
border-color: #e6f0ff;
border-radius: 8px;
&:hover {
border-color: #0065ff;
}
`}
>
<Center
p="6px"
bg="#E6F0FF"
css={css`
border-radius: 4px;
`}
>
<Icon
fontSize="24px"
color="#0065FF"
as={iconsList[preset.icon]}
/>
</Center>
<Center pl={2} pt="0">
<Text>{preset.id}</Text>
</Center>
</Flex>
) : undefined
)}
</Box>
</Scrollbars>
</Box>
</PopoverContent>
</Popover>
);
})}
</div>
</Box>
</PopoverContent>
</Portal>
</>
)}
</Popover>
</Box>
);
});