diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx index 5c4fce184a2..92222dc4566 100644 --- a/packages/app-page-builder/src/PageBuilder.tsx +++ b/packages/app-page-builder/src/PageBuilder.tsx @@ -64,6 +64,11 @@ const PageBuilderMenu: React.FC = () => { label={"Categories"} path="/page-builder/block-categories" /> + diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx index 6057c3ea8ab..8f9e1cd2148 100644 --- a/packages/app-page-builder/src/admin/plugins/routes.tsx +++ b/packages/app-page-builder/src/admin/plugins/routes.tsx @@ -11,6 +11,7 @@ import Menus from "../views/Menus/Menus"; import Pages from "../views/Pages/Pages"; import Editor from "../views/Pages/Editor"; import BlockCategories from "../views/BlockCategories/BlockCategories"; +import PageBlocks from "../views/PageBlocks/PageBlocks"; const ROLE_PB_CATEGORY = "pb.category"; const ROLE_PB_MENUS = "pb.menu"; @@ -111,6 +112,24 @@ const plugins: RoutePlugin[] = [ )} /> ) + }, + { + name: "route-pb-page-blocks", + type: "route", + route: ( + ( + + + + + + + )} + /> + ) } ]; diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx index 18e059ecc66..4a1e696d877 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx @@ -28,7 +28,7 @@ import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18 import { ReactComponent as FilterIcon } from "@webiny/app-admin/assets/icons/filter-24px.svg"; import { PageBuilderSecurityPermission, PbBlockCategory } from "~/types"; -const t = i18n.ns("app-page-builder/admin/categories/data-list"); +const t = i18n.ns("app-page-builder/admin/block-categories/data-list"); interface CreatableItem { createdBy?: { diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx index eb441328504..27facff39e1 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx @@ -38,7 +38,7 @@ import isEmpty from "lodash/isEmpty"; import EmptyView from "@webiny/app-admin/components/EmptyView"; import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18px.svg"; -const t = i18n.ns("app-page-builder/admin/categories/form"); +const t = i18n.ns("app-page-builder/admin/block-categories/form"); const ButtonWrapper = styled("div")({ display: "flex", diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts b/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts index 40ac2be3578..a582fdfd7b2 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts +++ b/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts @@ -1,7 +1,7 @@ import gql from "graphql-tag"; import { PbBlockCategory, PbErrorResponse } from "~/types"; -const BASE_FIELDS = ` +export const PAGE_BLOCK_CATEGORY_BASE_FIELDS = ` slug name createdOn @@ -16,7 +16,7 @@ export const LIST_BLOCK_CATEGORIES = gql` pageBuilder { listBlockCategories { data { - ${BASE_FIELDS} + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} } error { data @@ -47,7 +47,7 @@ export const GET_BLOCK_CATEGORY = gql` pageBuilder { getBlockCategory(slug: $slug){ data { - ${BASE_FIELDS} + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} } error { code @@ -81,7 +81,7 @@ export const CREATE_BLOCK_CATEGORY = gql` pageBuilder { blockCategory: createBlockCategory(data: $data) { data { - ${BASE_FIELDS} + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} } error { code @@ -117,7 +117,7 @@ export const UPDATE_BLOCK_CATEGORY = gql` pageBuilder { blockCategory: updateBlockCategory(slug: $slug, data: $data) { data { - ${BASE_FIELDS} + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} } error { code diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx new file mode 100644 index 00000000000..2be05c90455 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { i18n } from "@webiny/app/i18n"; +import { useRouter } from "@webiny/react-router"; +import { useQuery } from "@apollo/react-hooks"; +import orderBy from "lodash/orderBy"; + +import { + DataList, + DataListModalOverlay, + DataListModalOverlayAction, + ScrollList, + ListItem, + ListItemText, + ListItemTextSecondary +} from "@webiny/ui/List"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { Select } from "@webiny/ui/Select"; +import SearchUI from "@webiny/app-admin/components/SearchUI"; +import { ReactComponent as FilterIcon } from "@webiny/app-admin/assets/icons/filter-24px.svg"; + +import { PbBlockCategory, PbPageBlock } from "~/types"; +import { LIST_PAGE_BLOCKS_AND_CATEGORIES } from "./graphql"; + +const t = i18n.ns("app-page-builder/admin/page-blocks/by-categories-data-list"); + +interface Sorter { + label: string; + sort: string; +} +const SORTERS: Sorter[] = [ + { + label: t`Newest to oldest`, + sort: "createdOn_DESC" + }, + { + label: t`Oldest to newest`, + sort: "createdOn_ASC" + }, + { + label: t`Name A-Z`, + sort: "name_ASC" + }, + { + label: t`Name Z-A`, + sort: "name_DESC" + } +]; + +const BlocksByCategoriesDataList = () => { + const [filter, setFilter] = useState(""); + const [sort, setSort] = useState(SORTERS[0].sort); + const { history } = useRouter(); + const listQuery = useQuery(LIST_PAGE_BLOCKS_AND_CATEGORIES); + + const blockCategoriesData: PbBlockCategory[] = + listQuery?.data?.pageBuilder?.listBlockCategories?.data || []; + const pageBlocksData: PbPageBlock[] = listQuery?.data?.pageBuilder?.listPageBlocks?.data || []; + + const filterData = useCallback( + ({ slug, name }) => { + return slug.toLowerCase().includes(filter) || name.toLowerCase().includes(filter); + }, + [filter] + ); + + const sortData = useCallback( + categories => { + if (!sort) { + return categories; + } + const [field, order] = sort.split("_"); + return orderBy(categories, field, order.toLowerCase() as "asc" | "desc"); + }, + [sort] + ); + + const selectedBlocksCategory = new URLSearchParams(location.search).get("category"); + const loading = [listQuery].find(item => item.loading); + + const blockCategoriesDataListModalOverlay = useMemo( + () => ( + + + + + + + + ), + [sort] + ); + + const filteredBlockCategoriesData: PbBlockCategory[] = + filter === "" ? blockCategoriesData : blockCategoriesData.filter(filterData); + const categoryList: PbBlockCategory[] = sortData(filteredBlockCategoriesData); + + return ( + + } + modalOverlay={blockCategoriesDataListModalOverlay} + modalOverlayAction={ + } + data-testid={"default-data-list.filter"} + /> + } + refresh={() => { + if (!listQuery.refetch) { + return; + } + listQuery.refetch(); + }} + > + {({ data }: { data: PbBlockCategory[] }) => ( + + {data.map(item => { + const numberOfBlocks = pageBlocksData.filter( + pageBlock => pageBlock.blockCategory === item.slug + ).length; + return ( + + + history.push( + `/page-builder/page-blocks?category=${item.slug}` + ) + } + > + {item.name} + {`${numberOfBlocks} ${ + numberOfBlocks === 1 ? "block" : "blocks" + } in the category`} + + + ); + })} + + )} + + ); +}; + +export default BlocksByCategoriesDataList; diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocks.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocks.tsx new file mode 100644 index 00000000000..3d7077d95a5 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocks.tsx @@ -0,0 +1,63 @@ +import React, { useMemo, useCallback } from "react"; +import { SplitView, LeftPanel, RightPanel } from "@webiny/app-admin/components/SplitView"; +import { useSecurity } from "@webiny/app-security"; + +import { PageBuilderSecurityPermission } from "~/types"; +import BlocksByCategoriesDataList from "./BlocksByCategoriesDataList"; +import PageBlocksDataList from "./PageBlocksDataList"; + +export interface CreatableItem { + createdBy?: { + id?: string; + }; +} + +const PageBlocks: React.FC = () => { + const { identity, getPermission } = useSecurity(); + const pbPageBlockPermission = useMemo((): PageBuilderSecurityPermission | null => { + return getPermission("pb.block"); + }, [identity]); + + const canEdit = useCallback((item: CreatableItem): boolean => { + if (!pbPageBlockPermission) { + return false; + } + if (pbPageBlockPermission.own) { + const identityId = identity ? identity.id || identity.login : null; + return item.createdBy?.id === identityId; + } + if (typeof pbPageBlockPermission.rwd === "string") { + return pbPageBlockPermission.rwd.includes("w"); + } + + return true; + }, []); + + const canDelete = useCallback((item: CreatableItem): boolean => { + if (!pbPageBlockPermission) { + return false; + } + if (pbPageBlockPermission.own) { + const identityId = identity ? identity.id || identity.login : null; + return item.createdBy?.id === identityId; + } + if (typeof pbPageBlockPermission.rwd === "string") { + return pbPageBlockPermission.rwd.includes("d"); + } + + return true; + }, []); + + return ( + + + + + + + + + ); +}; + +export default PageBlocks; diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx new file mode 100644 index 00000000000..5bf49f4b479 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx @@ -0,0 +1,195 @@ +import React, { useCallback, useEffect } from "react"; +import styled from "@emotion/styled"; +import { useQuery, useMutation } from "@apollo/react-hooks"; +import isEmpty from "lodash/isEmpty"; + +import { useRouter } from "@webiny/react-router"; +import { DeleteIcon, EditIcon } from "@webiny/ui/List/DataList/icons"; +import { CircularProgress } from "@webiny/ui/Progress"; +import EmptyView from "@webiny/app-admin/components/EmptyView"; +import { Typography } from "@webiny/ui/Typography"; +import { i18n } from "@webiny/app/i18n"; +import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { useConfirmationDialog } from "@webiny/app-admin/hooks/useConfirmationDialog"; + +import { PbPageBlock } from "~/types"; +import { LIST_PAGE_BLOCKS, LIST_PAGE_BLOCKS_AND_CATEGORIES, DELETE_PAGE_BLOCK } from "./graphql"; +import { CreatableItem } from "./PageBlocks"; +import PageBlocksForm from "./PageBlocksForm"; + +const t = i18n.ns("app-page-builder/admin/page-blocks/data-list"); + +const List = styled("div")({ + display: "grid", + rowGap: "8px", + padding: "8px", + margin: "17px 50px", + backgroundColor: "white", + boxShadow: + "0px 2px 1px -1px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%)" +}); + +const ListItem = styled("div")({ + position: "relative", + display: "flex", + alignItems: "end", + border: "1px solid rgba(212, 212, 212, 0.5)", + boxShadow: + "0px 2px 1px -1px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%)", + height: "120px", + padding: "24px" +}); + +const ListItemText = styled("span")({ + textTransform: "uppercase" +}); + +const Controls = styled("div")({ + position: "absolute", + top: 0, + bottom: 0, + left: 0, + right: 0, + opacity: 0, + backgroundColor: "rgba(0,0,0,0.5)", + transition: "opacity 0.2s ease-out", + + "&:hover": { + opacity: 1 + } +}); + +const DeleteButton = styled(DeleteIcon)({ + position: "absolute", + top: "10px", + right: "10px", + + "& svg": { + fill: "white" + } +}); + +const EditButton = styled(EditIcon)({ + position: "absolute", + top: "10px", + left: "10px", + + "& svg": { + fill: "white" + } +}); + +const NoRecordsWrapper = styled("div")({ + textAlign: "center", + padding: 100, + color: "var(--mdc-theme-on-surface)" +}); + +type PageBlocksDataListProps = { + canEdit: (item: CreatableItem) => boolean; + canDelete: (item: CreatableItem) => boolean; +}; + +const PageBlocksDataList = ({ canEdit, canDelete }: PageBlocksDataListProps) => { + const { history, location } = useRouter(); + const { showSnackbar } = useSnackbar(); + const { showConfirmation } = useConfirmationDialog(); + + const selectedBlocksCategory = new URLSearchParams(location.search).get("category"); + + const { data, loading, refetch } = useQuery(LIST_PAGE_BLOCKS, { + variables: { blockCategory: selectedBlocksCategory as string }, + skip: !selectedBlocksCategory, + onCompleted: data => { + const error = data?.pageBuilder?.listPageBlocks?.error; + if (error) { + history.push("/page-builder/page-blocks"); + showSnackbar(error.message); + } + } + }); + + useEffect(() => { + if (selectedBlocksCategory) { + refetch(); + } + }, [selectedBlocksCategory]); + + const [deleteIt, deleteMutation] = useMutation(DELETE_PAGE_BLOCK, { + refetchQueries: [{ query: LIST_PAGE_BLOCKS_AND_CATEGORIES }], //To update block counters on the left side + onCompleted: () => refetch() + }); + + const pageBlocksData: PbPageBlock[] = data?.pageBuilder?.listPageBlocks?.data || []; + + const deleteItem = useCallback( + item => { + showConfirmation(async () => { + const response = await deleteIt({ + variables: item + }); + + const error = response?.data?.pageBuilder?.deletePageBlock?.error; + if (error) { + return showSnackbar(error.message); + } + + showSnackbar(t`Block "{name}" deleted.`({ name: item.name })); + }); + }, + [selectedBlocksCategory] + ); + + const isLoading = [deleteMutation].find(item => item.loading) || loading; + + const showEmptyView = !isLoading && !selectedBlocksCategory; + // Render "No content selected" view. + if (showEmptyView) { + return ( + + ); + } + + const showNoRecordsView = !isLoading && isEmpty(pageBlocksData); + // Render "No records found" view. + if (showNoRecordsView) { + return ( + + No records found. + + ); + } + + return ( + <> + + {isLoading && } + {pageBlocksData.map(pageBlock => ( + + {pageBlock.name} + + {canEdit(pageBlock) && ( + + history.push( + `/page-builder/page-blocks?category=${selectedBlocksCategory}&id=${pageBlock.id}` + ) + } + /> + )} + {canDelete(pageBlock) && ( + deleteItem(pageBlock)} /> + )} + + + ))} + + + + ); +}; + +export default PageBlocksDataList; diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksForm.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksForm.tsx new file mode 100644 index 00000000000..7fa9714928b --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksForm.tsx @@ -0,0 +1,156 @@ +import React, { useCallback } from "react"; +import styled from "@emotion/styled"; +import { useMutation, useQuery } from "@apollo/react-hooks"; +import pick from "lodash/pick"; + +import { i18n } from "@webiny/app/i18n"; +import { Form } from "@webiny/form"; +import { Grid, Cell } from "@webiny/ui/Grid"; +import { ButtonPrimary } from "@webiny/ui/Button"; +import { CircularProgress } from "@webiny/ui/Progress"; +import { SimpleFormContent } from "@webiny/app-admin/components/SimpleForm"; +import { validation } from "@webiny/validation"; +import { useRouter } from "@webiny/react-router"; +import { Input } from "@webiny/ui/Input"; +import { Select } from "@webiny/ui/Select"; +import { Checkbox } from "@webiny/ui/Checkbox"; +import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { Dialog, DialogCancel, DialogTitle, DialogActions, DialogContent } from "@webiny/ui/Dialog"; + +import { + LIST_PAGE_BLOCKS_AND_CATEGORIES, + LIST_BLOCK_CATEGORIES, + UPDATE_PAGE_BLOCK, + UpdatePageBlockMutationResponse, + UpdatePageBlockMutationVariables +} from "./graphql"; +import { PbPageBlock, PbBlockCategory } from "~/types"; + +const t = i18n.ns("app-page-builder/admin/page-blocks/form"); + +const ButtonWrapper = styled("div")({ + display: "flex", + justifyContent: "space-between", + width: "100%" +}); + +interface PageBlocksFormProps { + pageBlocksData: PbPageBlock[]; + refetch: () => {}; +} + +const PageBlocksForm: React.FC = ({ pageBlocksData, refetch }) => { + const { history } = useRouter(); + const { showSnackbar } = useSnackbar(); + + const id = new URLSearchParams(location.search).get("id"); + const category = new URLSearchParams(location.search).get("category"); + + const listQuery = useQuery(LIST_BLOCK_CATEGORIES); + const blockCategories: PbBlockCategory[] = + listQuery?.data?.pageBuilder?.listBlockCategories?.data || []; + + const [update, updateMutation] = useMutation< + UpdatePageBlockMutationResponse, + UpdatePageBlockMutationVariables + >(UPDATE_PAGE_BLOCK, { + refetchQueries: [{ query: LIST_PAGE_BLOCKS_AND_CATEGORIES }], //To update block counters on the left side + onCompleted: () => refetch() + }); + + const onSubmit = useCallback( + async formData => { + const data = pick(formData, ["name", "blockCategory"]); + + const response = await update({ + variables: { id: id as string, data } + }); + + const error = response?.data?.pageBuilder?.pageBlock?.error; + if (error) { + showSnackbar(error.message); + return; + } + + history.push(`/page-builder/page-blocks?category=${category}`); + + showSnackbar(t`Block saved successfully.`); + }, + [id] + ); + + const data = pageBlocksData.find(pageBlock => pageBlock.id === id); + + const loading = [updateMutation].find(item => item.loading); + + return ( + history.push(`/page-builder/page-blocks?category=${category}`)} + > +
+ {({ form, Bind }) => ( + <> + {loading && } + Edit Block + + + + + + + + + + + + + + + + + + + + + + + + + history.push( + `/page-builder/page-blocks?category=${category}` + ) + } + > + Cancel + + { + form.submit(ev); + }} + > + Save Block + + + + + )} + +
+ ); +}; + +export default PageBlocksForm; diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/graphql.ts b/packages/app-page-builder/src/admin/views/PageBlocks/graphql.ts new file mode 100644 index 00000000000..cca49f3342e --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/graphql.ts @@ -0,0 +1,155 @@ +import gql from "graphql-tag"; + +import { PbPageBlock, PbErrorResponse } from "~/types"; + +import { PAGE_BLOCK_CATEGORY_BASE_FIELDS } from "~/admin/views/BlockCategories/graphql"; +export { LIST_BLOCK_CATEGORIES } from "~/admin/views/BlockCategories/graphql"; + +const PAGE_BLOCK_BASE_FIELDS = ` + id + blockCategory + preview + name + content + createdOn + createdBy { + id + displayName + type + } +`; + +export const LIST_PAGE_BLOCKS_AND_CATEGORIES = gql` + query ListBlockCategories { + pageBuilder { + listBlockCategories { + data { + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} + } + error { + code + data + message + } + } + listPageBlocks { + data { + ${PAGE_BLOCK_BASE_FIELDS} + } + error { + code + data + message + } + } + } + } +`; +/** + * ############################## + * List Page Blocks Query + */ +export interface ListPageBlocksQueryResponse { + pageBuilder: { + data?: PbPageBlock[]; + error?: PbErrorResponse; + }; +} +export interface ListPageBlocksQueryVariables { + blockCategory: string; +} +export const LIST_PAGE_BLOCKS = gql` + query ListPageBlocks($blockCategory: String) { + pageBuilder { + listPageBlocks(where: {blockCategory:$blockCategory}) { + data { + ${PAGE_BLOCK_BASE_FIELDS} + } + error { + code + data + message + } + } + } + } +`; +/** + * ########################### + * Create Page Block Mutation Response + */ +export interface CreatePageBlockMutationResponse { + pageBuilder: { + pageBlock: { + data: PbPageBlock | null; + error: PbErrorResponse | null; + }; + }; +} +export interface CreatePageBlockMutationVariables { + data: PbPageBlock; +} +export const CREATE_PAGE_BLOCK = gql` + mutation CreatePageBlock($data: PbCreatePageBlockInput!){ + pageBuilder { + pageBlock: createPageBlock(data: $data) { + data { + ${PAGE_BLOCK_BASE_FIELDS} + } + error { + code + message + data + } + } + } + } +`; +/** + * ########################### + * Update Page Block Mutation Response + */ +export interface UpdatePageBlockMutationResponse { + pageBuilder: { + pageBlock: { + data: PbPageBlock | null; + error: PbErrorResponse | null; + }; + }; +} +export interface UpdatePageBlockMutationVariables { + id: string; + data: { + name: string; + blockCategory: string; + }; +} +export const UPDATE_PAGE_BLOCK = gql` + mutation UpdatePageBlock($id: ID!, $data: PbUpdatePageBlockInput!){ + pageBuilder { + pageBlock: updatePageBlock(id: $id, data: $data) { + data { + ${PAGE_BLOCK_BASE_FIELDS} + } + error { + code + message + data + } + } + } + } +`; + +export const DELETE_PAGE_BLOCK = gql` + mutation DeletePageBlock($id: ID!) { + pageBuilder { + deletePageBlock(id: $id) { + error { + code + message + } + } + } + } +`; diff --git a/packages/app-page-builder/src/types.ts b/packages/app-page-builder/src/types.ts index cee2c4e984e..3abb47d00f4 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -820,6 +820,16 @@ export interface PbBlockCategory { createdBy: PbIdentity; } +export interface PbPageBlock { + id: string; + name: string; + blockCategory: string; + content: File; + preview: File; + createdOn: string; + createdBy: PbIdentity; +} + /** * TODO: have types for both API and app in the same package? * GraphQL response types