Example

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>
  );
});

Last updated