From b3beff097ba59deb0ad5a7d98122c0f15d1aa1bd Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 7 Jan 2025 11:04:49 +0800 Subject: [PATCH 01/66] task: create new component --- .../src/app/components/Editor/Field/Field.tsx | 28 ++++++++---- .../components/RelationalFieldBase/Item.tsx | 9 ++++ .../components/RelationalFieldBase/index.tsx | 43 +++++++++++++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 src/shell/components/RelationalFieldBase/Item.tsx create mode 100644 src/shell/components/RelationalFieldBase/index.tsx diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx index b99289a63..001479def 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx @@ -45,10 +45,11 @@ import { FieldTypeOneToMany, OneToManyOptions, } from "../../../../../../../shell/components/FieldTypeOneToMany"; -import { - FieldTypeOneToOne, - OneToOneOptions, -} from "../../../../../../../shell/components/FieldTypeOneToOne"; +// import { +// FieldTypeOneToOne, +// OneToOneOptions, +// } from "../../../../../../../shell/components/FieldTypeOneToOne"; +import { RelationalFieldBase } from "../../../../../../../shell/components/RelationalFieldBase"; import { FieldTypeDate } from "../../../../../../../shell/components/FieldTypeDate"; import { FieldTypeDateTime } from "../../../../../../../shell/components/FieldTypeDateTime"; import { FieldTypeSort } from "../../../../../../../shell/components/FieldTypeSort"; @@ -91,7 +92,7 @@ export const resolveRelatedOptions = ( modelZUID: string, langID: number, value: any -): OneToManyOptions[] | OneToOneOptions[] => { +): OneToManyOptions[] => { // guard against absent data in state const field = fields && fields[fieldZUID]; if (!field || !items) { @@ -770,7 +771,11 @@ export const Field = ({ return ( - + {/* options.value === value) || @@ -790,7 +795,7 @@ export const Field = ({ value && {getSelectedLang(allLanguages, langID)} } error={errors && Object.values(errors)?.some((error) => !!error)} - /> + /> */} ); @@ -829,7 +834,12 @@ export const Field = ({ return ( - + {/* !!error)} - /> + /> */} ); diff --git a/src/shell/components/RelationalFieldBase/Item.tsx b/src/shell/components/RelationalFieldBase/Item.tsx new file mode 100644 index 000000000..a66204224 --- /dev/null +++ b/src/shell/components/RelationalFieldBase/Item.tsx @@ -0,0 +1,9 @@ +import { Typography } from "@mui/material"; + +type ItemProps = { + itemZUID: string; + draggable?: boolean; +}; +export const Item = ({ itemZUID, draggable }: ItemProps) => { + return {itemZUID}; +}; diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx new file mode 100644 index 000000000..ea64e7a98 --- /dev/null +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -0,0 +1,43 @@ +import { Box, Button, Stack, Typography } from "@mui/material"; +import { LinkRounded } from "@mui/icons-material"; + +import { Item } from "./Item"; +import { useGetContentModelQuery } from "../../services/instance"; + +type RelationalFieldBaseProps = { + value: string; + multiselect?: boolean; + relatedModelZUID: string; +}; +export const RelationalFieldBase = ({ + value, + multiselect, + relatedModelZUID, +}: RelationalFieldBaseProps) => { + const { data: modelData } = useGetContentModelQuery(relatedModelZUID, { + skip: !relatedModelZUID, + }); + + return ( + + + {value?.split(",")?.map((val) => ( + + ))} + + {(multiselect || (!multiselect && !value?.split(",")?.length)) && ( + + )} + + ); +}; From 1697f0c3b21f5fe35e2b434bb27ae0f6be1996d8 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 7 Jan 2025 11:35:04 +0800 Subject: [PATCH 02/66] task: show model name --- src/shell/components/RelationalFieldBase/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index ea64e7a98..17b9e0db2 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -35,7 +35,7 @@ export const RelationalFieldBase = ({ mt: 1, }} > - Add Existing Authors + Add Existing {modelData?.label} )} From 66479bf36939b505cf8c20182feefdb6060345af Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 8 Jan 2025 10:45:02 +0800 Subject: [PATCH 03/66] task: create active item component --- .../src/app/components/Editor/Field/Field.tsx | 2 + .../RelationalFieldBase/ActiveItem.tsx | 78 +++++++++++++++++++ .../components/RelationalFieldBase/Item.tsx | 9 --- .../components/RelationalFieldBase/index.tsx | 49 ++++++++++-- 4 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 src/shell/components/RelationalFieldBase/ActiveItem.tsx delete mode 100644 src/shell/components/RelationalFieldBase/Item.tsx diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx index 001479def..51b4f5511 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx @@ -774,6 +774,7 @@ export const Field = ({ {/* {/* { + const { data: contentItem, isLoading } = useGetContentItemQuery(itemZUID, { + skip: !itemZUID, + }); + + const itemTitle = + contentItem?.data[relatedFieldData?.name] || + contentItem?.web?.metaTitle || + contentItem?.web?.metaLinkText; + + return ( + + {draggable && ( + + + + )} + + {isLoading ? ( + <> + + + + ) : ( + <> + + {itemTitle} + + {contentItem?.web?.metaDescription && ( + + {contentItem?.web?.metaDescription} + + )} + + )} + + + ); + } +); + +ActiveItem.displayName = "ActiveItem"; diff --git a/src/shell/components/RelationalFieldBase/Item.tsx b/src/shell/components/RelationalFieldBase/Item.tsx deleted file mode 100644 index a66204224..000000000 --- a/src/shell/components/RelationalFieldBase/Item.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Typography } from "@mui/material"; - -type ItemProps = { - itemZUID: string; - draggable?: boolean; -}; -export const Item = ({ itemZUID, draggable }: ItemProps) => { - return {itemZUID}; -}; diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index 17b9e0db2..dd37fc8d0 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -1,28 +1,67 @@ +import { useEffect, useState } from "react"; import { Box, Button, Stack, Typography } from "@mui/material"; import { LinkRounded } from "@mui/icons-material"; -import { Item } from "./Item"; -import { useGetContentModelQuery } from "../../services/instance"; +import { ActiveItem } from "./ActiveItem"; +import { + useGetContentModelQuery, + useGetContentModelItemsQuery, + useSearchContentQuery, + useGetLangsQuery, + useGetContentModelFieldsQuery, +} from "../../services/instance"; type RelationalFieldBaseProps = { value: string; - multiselect?: boolean; relatedModelZUID: string; + relatedFieldZUID: string; + multiselect?: boolean; }; export const RelationalFieldBase = ({ value, - multiselect, relatedModelZUID, + relatedFieldZUID, + multiselect, }: RelationalFieldBaseProps) => { + const [langCode, setLangCode] = useState(""); + + const { data: langs } = useGetLangsQuery({}); const { data: modelData } = useGetContentModelQuery(relatedModelZUID, { skip: !relatedModelZUID, }); + const { data: contentItems } = useGetContentModelItemsQuery( + { + modelZUID: relatedModelZUID, + params: { + lang: langCode, + }, + }, + { skip: !relatedModelZUID || !langCode } + ); + const { data: modelFields } = useGetContentModelFieldsQuery( + relatedModelZUID, + { skip: !relatedModelZUID } + ); + + useEffect(() => { + if (langs?.length) { + setLangCode(langs?.find((lang) => lang.default)?.code); + } + }, [langs]); return ( {value?.split(",")?.map((val) => ( - + field.ZUID === relatedFieldZUID + )} + draggable={multiselect} + /> ))} {(multiselect || (!multiselect && !value?.split(",")?.length)) && ( From c0a9363aca39438c11083d291eb8f63efe9df328 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 8 Jan 2025 13:34:23 +0800 Subject: [PATCH 04/66] task: update loading skeleton --- .../src/app/components/Editor/Field/Field.tsx | 12 +- .../RelationalFieldBase/ActiveItem.tsx | 78 --------- .../ActiveItem/ActiveItemLoading.tsx | 80 ++++++++++ .../RelationalFieldBase/ActiveItem/index.tsx | 148 ++++++++++++++++++ .../components/RelationalFieldBase/index.tsx | 2 +- 5 files changed, 235 insertions(+), 85 deletions(-) delete mode 100644 src/shell/components/RelationalFieldBase/ActiveItem.tsx create mode 100644 src/shell/components/RelationalFieldBase/ActiveItem/ActiveItemLoading.tsx create mode 100644 src/shell/components/RelationalFieldBase/ActiveItem/index.tsx diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx index 51b4f5511..79e56feb0 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx @@ -45,10 +45,10 @@ import { FieldTypeOneToMany, OneToManyOptions, } from "../../../../../../../shell/components/FieldTypeOneToMany"; -// import { -// FieldTypeOneToOne, -// OneToOneOptions, -// } from "../../../../../../../shell/components/FieldTypeOneToOne"; +import { + FieldTypeOneToOne, + OneToOneOptions, +} from "../../../../../../../shell/components/FieldTypeOneToOne"; import { RelationalFieldBase } from "../../../../../../../shell/components/RelationalFieldBase"; import { FieldTypeDate } from "../../../../../../../shell/components/FieldTypeDate"; import { FieldTypeDateTime } from "../../../../../../../shell/components/FieldTypeDateTime"; @@ -772,7 +772,7 @@ export const Field = ({ return ( @@ -837,7 +837,7 @@ export const Field = ({ diff --git a/src/shell/components/RelationalFieldBase/ActiveItem.tsx b/src/shell/components/RelationalFieldBase/ActiveItem.tsx deleted file mode 100644 index e738a7d25..000000000 --- a/src/shell/components/RelationalFieldBase/ActiveItem.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { memo } from "react"; -import { Typography, Stack, Box, Skeleton, IconButton } from "@mui/material"; -import { DragIndicatorRounded } from "@mui/icons-material"; - -import { useGetContentItemQuery } from "../../services/instance"; -import { ContentModel, ContentModelField } from "../../services/types"; - -type ActiveItemProps = { - itemZUID: string; - relatedFieldData: ContentModelField; - relatedModelData: ContentModel; - draggable?: boolean; -}; -export const ActiveItem = memo( - ({ - itemZUID, - relatedFieldData, - relatedModelData, - draggable, - }: ActiveItemProps) => { - const { data: contentItem, isLoading } = useGetContentItemQuery(itemZUID, { - skip: !itemZUID, - }); - - const itemTitle = - contentItem?.data[relatedFieldData?.name] || - contentItem?.web?.metaTitle || - contentItem?.web?.metaLinkText; - - return ( - - {draggable && ( - - - - )} - - {isLoading ? ( - <> - - - - ) : ( - <> - - {itemTitle} - - {contentItem?.web?.metaDescription && ( - - {contentItem?.web?.metaDescription} - - )} - - )} - - - ); - } -); - -ActiveItem.displayName = "ActiveItem"; diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/ActiveItemLoading.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/ActiveItemLoading.tsx new file mode 100644 index 000000000..d0200f8e7 --- /dev/null +++ b/src/shell/components/RelationalFieldBase/ActiveItem/ActiveItemLoading.tsx @@ -0,0 +1,80 @@ +import { Stack, Skeleton, IconButton } from "@mui/material"; +import { DragIndicatorRounded, Edit, MoreHoriz } from "@mui/icons-material"; + +type ActiveItemLoadingProps = { + draggable?: boolean; +}; +export const ActiveItemLoading = ({ draggable }: ActiveItemLoadingProps) => { + return ( + + + {draggable && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx new file mode 100644 index 000000000..4e9c5ca99 --- /dev/null +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -0,0 +1,148 @@ +import { memo, useState, useMemo } from "react"; +import { Typography, Stack, Box, Skeleton, IconButton } from "@mui/material"; +import { DragIndicatorRounded, Edit, MoreHoriz } from "@mui/icons-material"; + +import { + useGetContentItemQuery, + useGetContentModelFieldsQuery, +} from "../../../services/instance"; +import { ContentModel, ContentModelField } from "../../../services/types"; +import { useLazyGetFileQuery } from "../../../services/mediaManager"; +import { fileExtension } from "../../../../apps/media/src/app/utils/fileUtils"; +import { ActiveItemLoading } from "./ActiveItemLoading"; + +type ActiveItemProps = { + itemZUID: string; + relatedFieldData: ContentModelField; + relatedModelData: ContentModel; + draggable?: boolean; +}; +export const ActiveItem = memo( + ({ + itemZUID, + relatedFieldData, + relatedModelData, + draggable, + }: ActiveItemProps) => { + const { data: contentItem, isLoading: isLoadingContentItem } = + useGetContentItemQuery(itemZUID, { + skip: !itemZUID, + }); + const { data: relatedModelFields, isLoading: isLoadingRelatedModel } = + useGetContentModelFieldsQuery(relatedModelData?.ZUID, { + skip: !relatedModelData?.ZUID, + }); + const [getFile, { isLoading: isLoadingImage }] = useLazyGetFileQuery(); + + const itemTitle = + contentItem?.data[relatedFieldData?.name] || + contentItem?.web?.metaTitle || + contentItem?.web?.metaLinkText; + + const isLoading = + isLoadingContentItem || isLoadingRelatedModel || isLoadingImage; + + const imageFields = useMemo(() => { + if (!relatedModelFields?.length) return []; + + return relatedModelFields.filter( + (field) => !field.deletedAt && field.datatype === "images" + ); + }, [relatedModelFields]); + + const imageURL = useMemo(() => { + if (!imageFields?.length || !contentItem) return null; + + let images: string[] = []; + + imageFields.forEach((field) => { + if (!!contentItem?.data?.[field.name]) { + const value = String(contentItem?.data?.[field.name]); + + if (value.startsWith("3-")) { + getFile(value) + .unwrap() + .then((res) => { + if ( + ["png", "jpg", "jpeg", "svg", "gif", "tif", "webp"].includes( + fileExtension(res.url) + ) + ) { + images = [...images, res.url]; + } + }); + } else { + images = [...images, value]; + } + } + }); + + return images?.[0]; + }, [imageFields, contentItem]); + + if (isLoading) { + return ; + } + + return ( + + {draggable && ( + + + + )} + {/* */} + + + {itemTitle} + + {contentItem?.web?.metaDescription && ( + + {contentItem?.web?.metaDescription} + + )} + + + ); + } +); + +ActiveItem.displayName = "ActiveItem"; diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index dd37fc8d0..1a3984586 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -64,7 +64,7 @@ export const RelationalFieldBase = ({ /> ))} - {(multiselect || (!multiselect && !value?.split(",")?.length)) && ( + {(multiselect || (!multiselect && !value)) && ( + )} {(multiselect || (!multiselect && !value)) && ( )} + {!!anchorEl && ( + setAnchorEl(null)} + modelZUID={relatedModelZUID} + modelName={modelData?.label} + /> + )} ); }; From 02ccc7af133102ee4b333660a316c9a4abb55844 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 14 Jan 2025 15:51:37 +0800 Subject: [PATCH 13/66] task: add remaining filters --- src/shell/components/Filters/FilterButton.tsx | 18 +- .../FieldSelectorFilters.tsx | 212 +++++++++++++++--- .../FieldSelectorDialog/index.tsx | 38 +++- 3 files changed, 236 insertions(+), 32 deletions(-) diff --git a/src/shell/components/Filters/FilterButton.tsx b/src/shell/components/Filters/FilterButton.tsx index 917d7d5e1..c12305741 100644 --- a/src/shell/components/Filters/FilterButton.tsx +++ b/src/shell/components/Filters/FilterButton.tsx @@ -6,7 +6,7 @@ import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; interface FilterButton { isFilterActive: boolean; - buttonText: string; + buttonText: string | React.ReactNode; onOpenMenu: (e: React.MouseEvent) => void; onRemoveFilter: (e: React.MouseEvent) => void; children?: React.ReactNode; @@ -32,7 +32,21 @@ export const FilterButton: FC = ({ onClick={onOpenMenu} data-cy={`${filterId}_selected`} > - {buttonText} + + {buttonText} + + + + + + + + ); +}; diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index 7cf886957..a4f467fe5 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -31,6 +31,7 @@ import { VersionCell } from "./VersionCell"; import { ItemsLoading } from "./ItemsLoading"; import { useGetUsersQuery } from "../../../services/accounts"; import { NoSearchResults } from "../../NoSearchResults"; +import { DialogHeader } from "./DialogHeader"; type FieldSelectorDialogProps = { onClose: () => void; @@ -208,6 +209,9 @@ export const FieldSelectorDialog = ({ [setFilterKeyword] ); + const isLoading = + isFetchingContentItems || isLoadingRelatedModel || isLoadingUsers; + return ( - - - Select {modelName} - - - - - + setSelectionModel([])} + onDone={() => onUpdateSelectedZUIDs(selectionModel as string[])} + loading={isLoading} + /> setLangFilter(langID)} /> - {isFetchingContentItems || isLoadingRelatedModel || isLoadingUsers ? ( + {isLoading ? ( ) : ( field.ZUID === relatedFieldZUID)?.name } selectedZUIDs={itemZUIDs} - onUpdateSelectedZUIDs={(selectedZUIDs) => + onUpdateSelectedZUIDs={(selectedZUIDs) => { onChange( !!selectedZUIDs?.length ? selectedZUIDs.join(",") : null, name - ) - } + ); + setAnchorEl(null); + }} /> )} From a6d6d252dc63f8d4ca5d1a1aa36fdbb316166d98 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Mon, 20 Jan 2025 13:31:47 +0800 Subject: [PATCH 25/66] chore: optimized filter state --- .../FieldSelectorFilters.tsx | 67 ++++++++----------- .../FieldSelectorDialog/index.tsx | 54 ++++++++++----- 2 files changed, 66 insertions(+), 55 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx index d352cd7a1..ad5213b1a 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx @@ -18,6 +18,7 @@ import { } from "../../../services/instance"; import { useGetUsersQuery } from "../../../services/accounts"; import { DateFilterValue, DateFilter } from "../../Filters/DateFilter"; +import { FieldFilters } from "./index"; const SORT_ORDER = { lastSaved: "Last Saved", @@ -65,30 +66,14 @@ const getCountryCode = (langCode: string) => { }; type FieldSelectorFiltersProps = { - sortOrder: string; - onUpdateSortOrder: (sortOrder: string) => void; - statusFilter: keyof typeof STATUS_FILTER; - onUpdateStatusFilter: (statusFilter: keyof typeof STATUS_FILTER) => void; modelZUID: string; - userFilter: string; - onUpdateUserFilter: (userZUID: string) => void; - dateFilter: DateFilterValue; - onUpdateDateFilter: (dateFilter: DateFilterValue) => void; - langFilter: number; - onUpdateLangFilter: (langID: number) => void; + filters: FieldFilters; + onUpdateFilter: (filter: Partial) => void; }; export const FieldSelectorFilters = ({ modelZUID, - sortOrder, - onUpdateSortOrder, - statusFilter, - onUpdateStatusFilter, - userFilter, - onUpdateUserFilter, - dateFilter, - onUpdateDateFilter, - langFilter, - onUpdateLangFilter, + filters, + onUpdateFilter, }: FieldSelectorFiltersProps) => { const [anchorEl, setAnchorEl] = useState({ currentTarget: null, @@ -109,8 +94,8 @@ export const FieldSelectorFilters = ({ }, [users]); const selectedLang = useMemo(() => { - return langs?.find((lang) => lang.ID === langFilter); - }, [langs, langFilter]); + return langs?.find((lang) => lang.ID === filters.lang); + }, [langs, filters.lang]); const handleUpdateSortOrder = (sortOrder: string) => { setAnchorEl({ @@ -118,7 +103,8 @@ export const FieldSelectorFilters = ({ id: "", }); - onUpdateSortOrder(sortOrder); + // onUpdateSortOrder(sortOrder); + onUpdateFilter({ sortOrder }); }; const getButtonText = (sortOrder: string) => { @@ -147,7 +133,7 @@ export const FieldSelectorFilters = ({ ) => { setAnchorEl({ currentTarget: event.currentTarget, @@ -179,8 +165,8 @@ export const FieldSelectorFilters = ({ onClick={() => handleUpdateSortOrder(key)} selected={ key === "lastSaved" - ? !sortOrder || sortOrder === "lastSaved" - : sortOrder === key + ? !filters.sortOrder || filters.sortOrder === "lastSaved" + : filters.sortOrder === key } > {value} @@ -201,13 +187,13 @@ export const FieldSelectorFilters = ({ > handleUpdateSortOrder("createdBy")} > Created By handleUpdateSortOrder("zuid")} > ZUID @@ -235,7 +221,7 @@ export const FieldSelectorFilters = ({ handleUpdateSortOrder(field.name)} - selected={sortOrder === field.name} + selected={filters.sortOrder === field.name} > {field.label} @@ -245,8 +231,8 @@ export const FieldSelectorFilters = ({ ) => { setAnchorEl({ currentTarget: event.currentTarget, @@ -254,7 +240,8 @@ export const FieldSelectorFilters = ({ }); }} onRemoveFilter={() => { - onUpdateStatusFilter(null); + onUpdateFilter({ status: null }); + // onUpdateStatusFilter(null); }} /> { - onUpdateStatusFilter(key as keyof typeof STATUS_FILTER); + // onUpdateStatusFilter(key as keyof typeof STATUS_FILTER); + onUpdateFilter({ status: key as keyof typeof STATUS_FILTER }); setAnchorEl({ currentTarget: null, id: "", }); }} - selected={statusFilter === key} + selected={filters.status === key} > {value} ))} onUpdateFilter({ user })} defaultButtonText="Created By" options={userOptions} /> onUpdateFilter({ date })} + value={filters.date} /> void; modelZUID: string; @@ -61,12 +75,30 @@ export const FieldSelectorDialog = ({ type: "", value: "", }); - const [langFilter, setLangFilter] = useState(null); + // const [langFilter, setLangFilter] = useState(null); + const [filters, updateFilters] = useReducer( + (state: FieldFilters, newValue: Partial) => { + return { + ...state, + ...newValue, + }; + }, + { + sortOrder: "lastSaved", + user: "", + date: { + type: "", + value: "", + }, + lang: null, + status: null, + } + ); const [selectionModel, setSelectionModel] = useState(selectedZUIDs); const { data: langs } = useGetLangsQuery({}); - const langCode = langs?.find((lang) => lang.ID === langFilter)?.code; + const langCode = langs?.find((lang) => lang.ID === filters.lang)?.code; const { data: contentItems, isFetching: isFetchingContentItems } = useGetContentModelItemsQuery( { @@ -85,7 +117,7 @@ export const FieldSelectorDialog = ({ useEffect(() => { if (!!langs?.length) { - setLangFilter(langs.find((lang) => lang.default)?.ID); + updateFilters({ lang: langs.find((lang) => lang.default)?.ID }); } }, [langs]); @@ -267,18 +299,8 @@ export const FieldSelectorDialog = ({ /> setSortOrder(newSortOrder)} - statusFilter={statusFilter} - onUpdateStatusFilter={(newStatusFilter) => - setStatusFilter(newStatusFilter) - } - userFilter={userFilter} - onUpdateUserFilter={(userZUID) => setUserFilter(userZUID)} - dateFilter={dateFilter} - onUpdateDateFilter={(newDateFilter) => setDateFilter(newDateFilter)} - langFilter={langFilter} - onUpdateLangFilter={(langID) => setLangFilter(langID)} + filters={filters} + onUpdateFilter={updateFilters} /> {isLoading ? ( From bf5316364a3cda609213cc4864726170bd536a9d Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Mon, 20 Jan 2025 13:34:10 +0800 Subject: [PATCH 26/66] chore: cleanup --- .../FieldSelectorDialog/index.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index 1e1ff605a..3154ec19c 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -8,15 +8,12 @@ import { } from "react"; import { Dialog, - DialogTitle, DialogContent, - Typography, TextField, - IconButton, InputAdornment, Box, } from "@mui/material"; -import { CloseRounded, Search } from "@mui/icons-material"; +import { Search } from "@mui/icons-material"; import { DataGridPro, GridColumns, @@ -67,15 +64,6 @@ export const FieldSelectorDialog = ({ }: FieldSelectorDialogProps) => { const searchField = useRef(null); const [filterKeyword, setFilterKeyword] = useState(null); - const [sortOrder, setSortOrder] = useState("lastSaved"); - const [statusFilter, setStatusFilter] = - useState(null); - const [userFilter, setUserFilter] = useState(null); - const [dateFilter, setDateFilter] = useState({ - type: "", - value: "", - }); - // const [langFilter, setLangFilter] = useState(null); const [filters, updateFilters] = useReducer( (state: FieldFilters, newValue: Partial) => { return { From b92fcc1371dc5d7611743726b3f63a28c1c8cb63 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Mon, 20 Jan 2025 13:56:34 +0800 Subject: [PATCH 27/66] task: handle deleted items --- .../RelationalFieldBase/ActiveItem/index.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index fbeb9548d..d927d6004 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -25,8 +25,8 @@ import { useHistory } from "react-router"; import { useDrag, useDrop } from "react-dnd"; import { - useGetContentItemQuery, useGetContentModelFieldsQuery, + useGetContentModelItemsQuery, } from "../../../services/instance"; import { ContentModel, ContentModelField } from "../../../services/types"; import { ActiveItemLoading } from "./ActiveItemLoading"; @@ -54,10 +54,11 @@ export const ActiveItem = memo( const [imageError, setImageError] = useState(false); const [anchorEl, setAnchorEl] = useState(null); const history = useHistory(); - const { data: contentItem, isLoading: isLoadingContentItem } = - useGetContentItemQuery(itemZUID, { - skip: !itemZUID, - }); + const { data: contentItems, isLoading: isLoadingContentItems } = + useGetContentModelItemsQuery( + { modelZUID: relatedModelData?.ZUID }, + { skip: !relatedModelData?.ZUID } + ); const { data: relatedModelFields, isLoading: isLoadingRelatedModel } = useGetContentModelFieldsQuery(relatedModelData?.ZUID, { skip: !relatedModelData?.ZUID, @@ -85,12 +86,9 @@ export const ActiveItem = memo( }, }); - const itemTitle = - contentItem?.data[relatedFieldData?.name] || - contentItem?.web?.metaTitle || - contentItem?.web?.metaLinkText; - - const isLoading = isLoadingContentItem || isLoadingRelatedModel; + const contentItem = useMemo(() => { + return contentItems?.find((item) => item.meta?.ZUID === itemZUID); + }, [contentItems]); const imageFieldName = useMemo(() => { if (!relatedModelFields?.length) return null; @@ -121,6 +119,14 @@ export const ActiveItem = memo( return null; }, [contentItem, imageFieldName]); + const itemTitle = + contentItem?.data[relatedFieldData?.name] || + contentItem?.web?.metaTitle || + contentItem?.web?.metaLinkText || + itemZUID; + + const isLoading = isLoadingContentItems || isLoadingRelatedModel; + if (isLoading) { return ; } From 89ed0bd432dfd91b5608f8a0d9836fc77b2efa2e Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 21 Jan 2025 11:46:07 +0800 Subject: [PATCH 28/66] task: preserve selected items when filtering --- .../FieldSelectorDialog/index.tsx | 35 ++++++++++++++----- .../components/RelationalFieldBase/index.tsx | 3 +- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index 3154ec19c..c182f5d38 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -222,6 +222,16 @@ export const FieldSelectorDialog = ({ })); }, [contentItems, relatedFieldName, imageFieldName, filterKeyword, users]); + const deletedItemZUIDs = useMemo(() => { + if (!contentItems?.length || !selectedZUIDs) return []; + + return ( + selectedZUIDs.filter( + (ZUID) => !contentItems?.find((item) => item.meta?.ZUID === ZUID) + ) || [] + ); + }, [contentItems, selectedZUIDs]); + const debouncedSetFilterKeyword = useCallback( debounce((value) => { setFilterKeyword(value); @@ -229,6 +239,17 @@ export const FieldSelectorDialog = ({ [setFilterKeyword] ); + const handleRowClick = (itemZUID: string) => { + console.log(itemZUID); + if ((selectionModel as string[]).includes(itemZUID)) { + setSelectionModel( + (selectionModel as string[]).filter((id) => id !== itemZUID) + ); + } else { + setSelectionModel([...(selectionModel as string[]), itemZUID]); + } + }; + const isLoading = isFetchingContentItems || isLoadingRelatedModel || isLoadingUsers; @@ -247,7 +268,11 @@ export const FieldSelectorDialog = ({ !deletedItemZUIDs?.includes(ZUID) + )?.length || 0 + } onClose={onClose} onDeselectAll={() => setSelectionModel([])} onDone={() => onUpdateSelectedZUIDs(selectionModel as string[])} @@ -329,13 +354,7 @@ export const FieldSelectorDialog = ({ rowHeight={64} hideFooter selectionModel={selectionModel} - onSelectionModelChange={(newSelectionModel) => { - if (!multiselect && newSelectionModel?.length > 1) { - return; - } - - setSelectionModel(newSelectionModel); - }} + onRowClick={(params) => handleRowClick(params.id as string)} sx={{ bgcolor: "background.paper", diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index 5a8420ee6..c356ef92a 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -31,7 +31,7 @@ export const RelationalFieldBase = ({ onChange, multiselect, }: RelationalFieldBaseProps) => { - const [itemZUIDs, setItemZUIDs] = useState(value?.split(",")); + const [itemZUIDs, setItemZUIDs] = useState(value?.split(",") || []); const [showAll, setShowAll] = useState(false); const [anchorEl, setAnchorEl] = useState(null); @@ -127,6 +127,7 @@ export const RelationalFieldBase = ({ !!selectedZUIDs?.length ? selectedZUIDs.join(",") : null, name ); + setItemZUIDs(!!selectedZUIDs?.length ? selectedZUIDs : null); setAnchorEl(null); }} /> From eab199d82766dd72d03202116ae70fd111328918 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 21 Jan 2025 11:49:20 +0800 Subject: [PATCH 29/66] task: hide version cell if content does not exist --- .../RelationalFieldBase/ActiveItem/index.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index d927d6004..3e3400653 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -236,11 +236,13 @@ export const ActiveItem = memo( - + {!!contentItem && ( + + )} Date: Tue, 21 Jan 2025 13:48:13 +0800 Subject: [PATCH 30/66] task: use redux store --- .../RelationalFieldBase/ActiveItem/index.tsx | 66 ++++++-- .../FieldSelectorFilters.tsx | 4 - .../FieldSelectorDialog/VersionCell.tsx | 128 +++------------ .../FieldSelectorDialog/index.tsx | 148 +++++++++++++----- .../components/RelationalFieldBase/index.tsx | 11 +- 5 files changed, 185 insertions(+), 172 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index 3e3400653..e7adf52f3 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -23,14 +23,14 @@ import { } from "@mui/icons-material"; import { useHistory } from "react-router"; import { useDrag, useDrop } from "react-dnd"; +import { useSelector } from "react-redux"; -import { - useGetContentModelFieldsQuery, - useGetContentModelItemsQuery, -} from "../../../services/instance"; +import { useGetContentModelFieldsQuery } from "../../../services/instance"; import { ContentModel, ContentModelField } from "../../../services/types"; import { ActiveItemLoading } from "./ActiveItemLoading"; import { VersionCell } from "../FieldSelectorDialog/VersionCell"; +import { AppState } from "../../../store/types"; +import { useGetUsersQuery } from "../../../services/accounts"; type ActiveItemProps = { itemZUID: string; @@ -54,15 +54,12 @@ export const ActiveItem = memo( const [imageError, setImageError] = useState(false); const [anchorEl, setAnchorEl] = useState(null); const history = useHistory(); - const { data: contentItems, isLoading: isLoadingContentItems } = - useGetContentModelItemsQuery( - { modelZUID: relatedModelData?.ZUID }, - { skip: !relatedModelData?.ZUID } - ); + const contentItems = useSelector((state: AppState) => state.content); const { data: relatedModelFields, isLoading: isLoadingRelatedModel } = useGetContentModelFieldsQuery(relatedModelData?.ZUID, { skip: !relatedModelData?.ZUID, }); + const { data: users, isLoading: isLoadingUsers } = useGetUsersQuery(); const [{ isDragging }, drag, preview] = useDrag({ type: "relationalItem", @@ -86,9 +83,48 @@ export const ActiveItem = memo( }, }); + const resolveUserZUID = (userZUID: string) => { + const user = users?.find((user) => user.ZUID === userZUID); + + if (!!user) { + return `${user?.firstName} ${user.lastName}`; + } + + return userZUID; + }; + const contentItem = useMemo(() => { - return contentItems?.find((item) => item.meta?.ZUID === itemZUID); - }, [contentItems]); + const item = Object.values(contentItems)?.find( + (item) => + item.meta?.ZUID === itemZUID && + item.meta?.contentModelZUID === relatedModelData?.ZUID + ); + + if (!item) { + return null; + } + + return { + ...item, + createdByName: resolveUserZUID(item.meta?.createdByUserZUID), + publishing: item?.publishing?.version + ? { + ...item.publishing, + publishedByName: resolveUserZUID( + item.publishing?.publishedByUserZUID + ), + } + : null, + scheduling: item?.scheduling?.version + ? { + ...item.scheduling, + scheduledByName: resolveUserZUID( + item.scheduling?.publishedByUserZUID + ), + } + : null, + }; + }, [contentItems, itemZUID, relatedModelData, users]); const imageFieldName = useMemo(() => { if (!relatedModelFields?.length) return null; @@ -125,9 +161,7 @@ export const ActiveItem = memo( contentItem?.web?.metaLinkText || itemZUID; - const isLoading = isLoadingContentItems || isLoadingRelatedModel; - - if (isLoading) { + if (isLoadingRelatedModel || isLoadingUsers) { return ; } @@ -238,9 +272,9 @@ export const ActiveItem = memo( {!!contentItem && ( )} diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx index ad5213b1a..e50c00ca4 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx @@ -103,7 +103,6 @@ export const FieldSelectorFilters = ({ id: "", }); - // onUpdateSortOrder(sortOrder); onUpdateFilter({ sortOrder }); }; @@ -241,7 +240,6 @@ export const FieldSelectorFilters = ({ }} onRemoveFilter={() => { onUpdateFilter({ status: null }); - // onUpdateStatusFilter(null); }} /> { - // onUpdateStatusFilter(key as keyof typeof STATUS_FILTER); onUpdateFilter({ status: key as keyof typeof STATUS_FILTER }); setAnchorEl({ currentTarget: null, @@ -331,7 +328,6 @@ export const FieldSelectorFilters = ({ currentTarget: null, id: "", }); - // onUpdateLangFilter(lang.ID); onUpdateFilter({ lang: lang.ID }); }} > diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/VersionCell.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/VersionCell.tsx index 466e5b87d..a7ed8f58d 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/VersionCell.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/VersionCell.tsx @@ -1,131 +1,41 @@ -import moment from "moment"; -import { useMemo } from "react"; -import { Stack, Skeleton } from "@mui/material"; +import { Stack } from "@mui/material"; -import { useGetUsersQuery } from "../../../services/accounts"; -import { useGetItemPublishingsQuery } from "../../../services/instance"; -import { ContentItem } from "../../../services/types"; +import { ContentItem, Publishing } from "../../../services/types"; import { VersionChip } from "../VersionChip"; type VersionCellProps = { - modelZUID: string; - itemZUID: string; - itemData: ContentItem; + itemData: ContentItem & { createdByName: string }; + publishData: Publishing & { publishedByName: string }; + scheduleData: Publishing & { scheduledByName: string }; }; export const VersionCell = ({ - modelZUID, - itemZUID, itemData, + publishData, + scheduleData, }: VersionCellProps) => { - const { data: users, isLoading: isLoadingUsers } = useGetUsersQuery(); - const { - data: contentItemPublishings, - isLoading: isLoadingContentItemPublishings, - } = useGetItemPublishingsQuery( - { - modelZUID: modelZUID, - itemZUID, - }, - { - skip: !modelZUID || !itemZUID, - } - ); - - const resolveUserZUID = (userZUID: string) => { - const user = users?.find((user) => user.ZUID === userZUID); - - if (!!user) { - return `${user?.firstName} ${user.lastName}`; - } - - return userZUID; - }; - - const publishStatus = useMemo(() => { - const publishedVersion = contentItemPublishings?.find( - (publishing) => publishing._active - ); - const scheduledVersion = contentItemPublishings?.find( - (publishing) => - !publishing._active && moment.utc().isBefore(publishing.publishAt) - ); - - return { - draft: - itemData?.meta?.version > (publishedVersion?.version || 0) - ? { - version: itemData?.meta?.version, - publisher: resolveUserZUID(itemData?.meta?.createdByUserZUID), - dateTime: itemData?.meta?.updatedAt, - } - : null, - published: !!publishedVersion - ? { - version: publishedVersion.version, - publisher: resolveUserZUID(publishedVersion.publishedByUserZUID), - dateTime: publishedVersion.publishAt, - } - : null, - scheduled: !!scheduledVersion - ? { - version: scheduledVersion.version, - publisher: resolveUserZUID(scheduledVersion.publishedByUserZUID), - dateTime: scheduledVersion.publishAt, - } - : null, - }; - }, [itemData, contentItemPublishings, users]); - - if (isLoadingUsers || isLoadingContentItemPublishings) { - return ( - - - - - - - - - ); - } - return ( - {!!publishStatus?.draft && ( + {itemData?.meta?.version > (publishData?.version || 0) && ( )} - {!!publishStatus?.scheduled ? ( + {!!scheduleData ? ( - ) : publishStatus?.published ? ( + ) : publishData ? ( ) : ( <> diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index c182f5d38..a4af3c97f 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -21,12 +21,12 @@ import { GridRenderCellParams, } from "@mui/x-data-grid-pro"; import { debounce } from "lodash"; +import { useDispatch, useSelector } from "react-redux"; import { FieldSelectorFilters, STATUS_FILTER } from "./FieldSelectorFilters"; import { DateFilterValue } from "../../Filters/DateFilter"; import { useGetLangsQuery, - useGetContentModelItemsQuery, useGetContentModelFieldsQuery, } from "../../../services/instance"; import { ImageCell } from "./ImageCell"; @@ -36,6 +36,25 @@ import { ItemsLoading } from "./ItemsLoading"; import { useGetUsersQuery } from "../../../services/accounts"; import { NoSearchResults } from "../../NoSearchResults"; import { DialogHeader } from "./DialogHeader"; +import { fetchItems } from "../../../store/content"; +import { AppState } from "../../../store/types"; +import { ContentItem } from "../../../services/types"; + +const selectFilteredItems = ( + state: AppState, + modelZUID: string, + activeLangId: number, + skip = false +) => { + if (skip) { + return []; + } + return Object.values(state.content).filter( + (item: ContentItem) => + item.meta.contentModelZUID === modelZUID && + item.meta.langID === activeLangId + ); +}; export type FieldFilters = { sortOrder: string; @@ -62,6 +81,7 @@ export const FieldSelectorDialog = ({ onUpdateSelectedZUIDs, multiselect, }: FieldSelectorDialogProps) => { + const dispatch = useDispatch(); const searchField = useRef(null); const [filterKeyword, setFilterKeyword] = useState(null); const [filters, updateFilters] = useReducer( @@ -84,19 +104,13 @@ export const FieldSelectorDialog = ({ ); const [selectionModel, setSelectionModel] = useState(selectedZUIDs); + const [isFetchingContentItems, setIsFetchingContentItems] = useState(false); const { data: langs } = useGetLangsQuery({}); const langCode = langs?.find((lang) => lang.ID === filters.lang)?.code; - const { data: contentItems, isFetching: isFetchingContentItems } = - useGetContentModelItemsQuery( - { - modelZUID, - params: { - lang: langCode, - }, - }, - { skip: !modelZUID || !langCode } - ); + const contentItems = useSelector((state: AppState) => + selectFilteredItems(state, modelZUID, filters.lang, isFetchingContentItems) + ); const { data: relatedModelFields, isLoading: isLoadingRelatedModel } = useGetContentModelFieldsQuery(modelZUID, { skip: !modelZUID, @@ -109,6 +123,21 @@ export const FieldSelectorDialog = ({ } }, [langs]); + useEffect(() => { + if (!!modelZUID) { + setIsFetchingContentItems(true); + dispatch( + fetchItems(modelZUID, { + lang: langCode, + limit: 5000, + }) + // @ts-ignore + ).then(() => { + setIsFetchingContentItems(false); + }); + } + }, [modelZUID, langCode]); + const imageFieldName = useMemo(() => { if (!relatedModelFields?.length) return null; @@ -136,9 +165,9 @@ export const FieldSelectorDialog = ({ width: 60, renderCell: (params: GridRenderCellParams) => ( ), }, @@ -164,24 +193,78 @@ export const FieldSelectorDialog = ({ return defaultCols; }, [imageFieldName]); - const rows = useMemo(() => { + const resolveUserZUID = (userZUID: string) => { + const user = users?.find((user) => user.ZUID === userZUID); + + if (!!user) { + return `${user?.firstName} ${user.lastName}`; + } + + return userZUID; + }; + + const mappedRows = useMemo(() => { if (!contentItems?.length || !users?.length) return []; - let mappedContentItems = [...contentItems]; + let _rows = [...contentItems]; + + return _rows?.map((item) => ({ + id: item.meta?.ZUID, + image: { + imageFieldName, + itemZUID: item.meta?.ZUID, + }, + title: { + primary: + item.data?.[relatedFieldName] || + item.web?.metaTitle || + item.web?.metaLinkText, + secondary: item.web?.metaDescription, + }, + version: { + itemData: { + ...item, + createdByName: resolveUserZUID(item.meta?.createdByUserZUID), + }, + publishData: item?.publishing?.version + ? { + ...item.publishing, + publishedByName: resolveUserZUID( + item.publishing?.publishedByUserZUID + ), + } + : null, + scheduleData: item?.scheduling?.version + ? { + ...item.scheduling, + scheduledByName: resolveUserZUID( + item.scheduling?.publishedByUserZUID + ), + } + : null, + }, + item, + })); + }, [contentItems, users, relatedFieldName, imageFieldName]); + + const rows = useMemo(() => { + if (!mappedRows?.length) return []; + + let _rows = [...mappedRows]; if (!!filterKeyword) { const search = filterKeyword.toLowerCase(); - mappedContentItems = mappedContentItems?.filter((item) => { + _rows = _rows?.filter((row) => { const matchedUser = users.find( - (user) => user.ZUID === item?.meta?.createdByUserZUID + (user) => user.ZUID === row?.item?.meta?.createdByUserZUID ); const creator = matchedUser ? `${matchedUser.firstName} ${matchedUser.lastName}` : null; return ( - Object.values(item.data).some((value: any) => { + Object.values(row?.item.data).some((value: any) => { if (!value) return false; if (value?.filename || value?.title) { @@ -193,34 +276,16 @@ export const FieldSelectorDialog = ({ return value.toString().toLowerCase().includes(search); }) || - item?.meta?.createdAt?.toLowerCase().includes(search) || - item?.web?.updatedAt?.toLowerCase().includes(search) || - item?.meta?.ZUID?.toLowerCase().includes(search) || + row?.item?.meta?.createdAt?.toLowerCase().includes(search) || + row?.item?.web?.updatedAt?.toLowerCase().includes(search) || + row?.item?.meta?.ZUID?.toLowerCase().includes(search) || creator?.toLowerCase()?.includes(search) ); }); } - return mappedContentItems?.map((item) => ({ - id: item.meta?.ZUID, - image: { - imageFieldName, - itemZUID: item.meta?.ZUID, - }, - title: { - primary: - item.data?.[relatedFieldName] || - item.web?.metaTitle || - item.web?.metaLinkText, - secondary: item.web?.metaDescription, - }, - version: { - modelZUID, - itemZUID: item?.meta?.ZUID, - itemData: item, - }, - })); - }, [contentItems, relatedFieldName, imageFieldName, filterKeyword, users]); + return _rows; + }, [mappedRows, filterKeyword]); const deletedItemZUIDs = useMemo(() => { if (!contentItems?.length || !selectedZUIDs) return []; @@ -240,7 +305,6 @@ export const FieldSelectorDialog = ({ ); const handleRowClick = (itemZUID: string) => { - console.log(itemZUID); if ((selectionModel as string[]).includes(itemZUID)) { setSelectionModel( (selectionModel as string[]).filter((id) => id !== itemZUID) diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index c356ef92a..93c318830 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useEffect, useState, useCallback } from "react"; import { Box, Button, Stack } from "@mui/material"; import { LinkRounded, @@ -7,6 +7,7 @@ import { } from "@mui/icons-material"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; +import { useDispatch } from "react-redux"; import { ActiveItem } from "./ActiveItem"; import { FieldSelectorDialog } from "./FieldSelectorDialog"; @@ -14,6 +15,7 @@ import { useGetContentModelQuery, useGetContentModelFieldsQuery, } from "../../services/instance"; +import { fetchItems } from "../../store/content"; type RelationalFieldBaseProps = { name: string; @@ -31,6 +33,7 @@ export const RelationalFieldBase = ({ onChange, multiselect, }: RelationalFieldBaseProps) => { + const dispatch = useDispatch(); const [itemZUIDs, setItemZUIDs] = useState(value?.split(",") || []); const [showAll, setShowAll] = useState(false); const [anchorEl, setAnchorEl] = useState(null); @@ -43,6 +46,12 @@ export const RelationalFieldBase = ({ { skip: !relatedModelZUID } ); + useEffect(() => { + if (!!relatedModelZUID) { + dispatch(fetchItems(relatedModelZUID)); + } + }, [relatedModelZUID]); + const handleMoveCard = useCallback( (draggedItemZUID: string, dropIndex: number) => { const draggedIndex = itemZUIDs.indexOf(draggedItemZUID); From b83c89efcb315aa380355c091fa3c71e8876ead5 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 22 Jan 2025 06:35:29 +0800 Subject: [PATCH 31/66] task: sorting --- .../FieldSelectorDialog/index.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index a4af3c97f..8f4f4cf61 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -252,6 +252,35 @@ export const FieldSelectorDialog = ({ let _rows = [...mappedRows]; + // Sorting + _rows?.sort((a: any, b: any) => { + if (!filters.sortOrder || filters.sortOrder === "lastSaved") { + const dateA = new Date(a.item?.web?.createdAt).getTime(); + const dateB = new Date(b.item?.web?.createdAt).getTime(); + + if (!a.item?.web?.createdAt) { + return -1; + } else if (!b.item?.web?.createdAt) { + return 1; + } else { + return dateB - dateA; + } + } else if (filters.sortOrder === "lastPublished") { + // Handle undefined publishAt by setting a default far-future date for sorting purposes + + let dateA = + a?.item?.scheduling?.publishAt || a?.item?.publishing?.publishAt; + dateA = dateA ? new Date(dateA).getTime() : Number.NEGATIVE_INFINITY; + + let dateB = + b?.item?.scheduling?.publishAt || b?.item?.publishing?.publishAt; + dateB = dateB ? new Date(dateB).getTime() : Number.NEGATIVE_INFINITY; + + return dateB - dateA; + } + }); + + // Keyword search if (!!filterKeyword) { const search = filterKeyword.toLowerCase(); From 56c84158aa9eb4d03e42c66bc057ea62cb6da847 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 22 Jan 2025 13:44:24 +0800 Subject: [PATCH 32/66] task: add sort logic --- .../FieldSelectorDialog/index.tsx | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index 8f4f4cf61..48c162f0d 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -39,6 +39,7 @@ import { DialogHeader } from "./DialogHeader"; import { fetchItems } from "../../../store/content"; import { AppState } from "../../../store/types"; import { ContentItem } from "../../../services/types"; +import moment from "moment"; const selectFilteredItems = ( state: AppState, @@ -85,7 +86,7 @@ export const FieldSelectorDialog = ({ const searchField = useRef(null); const [filterKeyword, setFilterKeyword] = useState(null); const [filters, updateFilters] = useReducer( - (state: FieldFilters, newValue: Partial) => { + (state: FieldFilters, newValue: Partial): FieldFilters => { return { ...state, ...newValue, @@ -277,6 +278,81 @@ export const FieldSelectorDialog = ({ dateB = dateB ? new Date(dateB).getTime() : Number.NEGATIVE_INFINITY; return dateB - dateA; + // return moment(dateB).diff(moment(dateA)); + } else if (filters.sortOrder === "createdOn") { + return moment(b?.item?.meta.createdAt).diff(a?.item?.meta.createdAt); + // new Date(b?.item?.meta.createdAt).getTime() - + // new Date(a?.item?.meta.createdAt).getTime() + } else if (filters.sortOrder === "version") { + const aIsPublished = a?.item?.publishing?.publishAt; + const bIsPublished = b?.item?.publishing?.publishAt; + + const aIsScheduled = a?.item?.scheduling?.publishAt; + const bIsScheduled = b?.item?.scheduling?.publishAt; + + // Check if meta.version exists + const aHasVersion = a?.item?.meta?.version !== null; + const bHasVersion = b?.item?.meta?.version !== null; + + // Place items without meta.version at the bottom + if (!aHasVersion && bHasVersion) { + return 1; + } else if (aHasVersion && !bHasVersion) { + return -1; + } + + // Items with only publish date + if (aIsPublished && !aIsScheduled && bIsPublished && !bIsScheduled) { + return ( + new Date(bIsPublished).getTime() - new Date(aIsPublished).getTime() + ); // Both have only published date, sort by publish date descending + } else if (aIsPublished && !aIsScheduled) { + return -1; // A has only published date, B does not + } else if (bIsPublished && !bIsScheduled) { + return 1; // B has only published date, A does not + } + + // Items with scheduled date (and also publish date) + if (aIsScheduled && bIsScheduled) { + return ( + new Date(aIsScheduled).getTime() - new Date(bIsScheduled).getTime() + ); // Both are scheduled, sort by scheduled date ascending + } else if (aIsScheduled) { + return -1; // A is scheduled, B is not + } else if (bIsScheduled) { + return 1; // B is scheduled, A is not + } + + // Items with neither publish nor schedule dates + if (aIsPublished && bIsPublished) { + return ( + new Date(bIsPublished).getTime() - new Date(aIsPublished).getTime() + ); // Both are published, sort by publish date descending + } else if (aIsPublished) { + return -1; // A is published, B is not + } else if (bIsPublished) { + return 1; // B is published, A is not + } + + return 0; // Neither are published or scheduled + } else if (filters.sortOrder === "createdBy") { + const userA = a?.version?.itemData?.createdByName; + const userB = b?.version?.itemData?.createdByName; + + const startsWithNumber = (str: string) => /^\d/.test(str); + + if (!userA || (startsWithNumber(userA) && !startsWithNumber(userB))) { + return 1; + } else if ( + !userB || + (!startsWithNumber(userA) && startsWithNumber(userB)) + ) { + return -1; + } else { + return userA.localeCompare(userB); + } + } else if (filters.sortOrder === "zuid") { + return a?.item?.meta?.ZUID?.localeCompare(b?.item?.meta?.ZUID); } }); @@ -440,6 +516,7 @@ export const FieldSelectorDialog = ({ /> ) : ( Date: Wed, 22 Jan 2025 14:16:36 +0800 Subject: [PATCH 33/66] task: per field sorting --- .../FieldSelectorDialog/index.tsx | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index 48c162f0d..f542ef8b9 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -255,7 +255,7 @@ export const FieldSelectorDialog = ({ // Sorting _rows?.sort((a: any, b: any) => { - if (!filters.sortOrder || filters.sortOrder === "lastSaved") { + if (filters.sortOrder === "lastSaved") { const dateA = new Date(a.item?.web?.createdAt).getTime(); const dateB = new Date(b.item?.web?.createdAt).getTime(); @@ -353,6 +353,68 @@ export const FieldSelectorDialog = ({ } } else if (filters.sortOrder === "zuid") { return a?.item?.meta?.ZUID?.localeCompare(b?.item?.meta?.ZUID); + } else if ( + relatedModelFields?.find((field) => field.name === filters.sortOrder) + ) { + const fieldName = filters.sortOrder; + const dataType = relatedModelFields?.find( + (field) => field.name === filters.sortOrder + )?.datatype; + + if (typeof a?.item?.data[fieldName] === "number") { + if (a?.item?.data[fieldName] == null) return 1; + if (b?.item?.data[fieldName] == null) return -1; + + if (dataType === "sort") { + return b?.item?.data[fieldName] - a?.item?.data[fieldName]; + } + + return b?.item?.data[fieldName] - a?.item?.data[fieldName]; + } + if (dataType === "date" || dataType === "datetime") { + if (!a?.item?.data[fieldName]) { + return 1; + } else if (!b?.item?.data[fieldName]) { + return -1; + } else { + return ( + new Date(b?.item?.data[fieldName]).getTime() - + new Date(a?.item?.data[fieldName]).getTime() + ); + } + } + + if (dataType === "yes_no") { + if (!a?.item?.data[fieldName]) { + return 1; + } else if (!b?.item?.data[fieldName]) { + return -1; + } else { + return b - a; + } + } + + const aValue = + dataType === "images" + ? a?.item?.data[fieldName]?.filename + : a?.item?.data[fieldName]; + const bValue = + dataType === "images" + ? b?.item?.data[fieldName]?.filename + : b?.item?.data[fieldName]; + + return aValue?.trim()?.localeCompare(bValue?.trim()); + } else { + const dateA = new Date(a.item?.web?.createdAt).getTime(); + const dateB = new Date(b.item?.web?.createdAt).getTime(); + + if (!a.item?.web?.createdAt) { + return -1; + } else if (!b.item?.web?.createdAt) { + return 1; + } else { + return dateB - dateA; + } } }); @@ -390,7 +452,7 @@ export const FieldSelectorDialog = ({ } return _rows; - }, [mappedRows, filterKeyword]); + }, [mappedRows, filterKeyword, relatedModelFields]); const deletedItemZUIDs = useMemo(() => { if (!contentItems?.length || !selectedZUIDs) return []; From 941c588fc7a063f78966e93da85ae6f27531704a Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 22 Jan 2025 15:45:17 +0800 Subject: [PATCH 34/66] task: field filtering logic --- .../FieldSelectorFilters.tsx | 139 +++++++++++++++++- .../FieldSelectorDialog/index.tsx | 47 +++++- 2 files changed, 181 insertions(+), 5 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx index e50c00ca4..c1eb3bf8e 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/FieldSelectorFilters.tsx @@ -19,6 +19,7 @@ import { import { useGetUsersQuery } from "../../../services/accounts"; import { DateFilterValue, DateFilter } from "../../Filters/DateFilter"; import { FieldFilters } from "./index"; +import { DateRangeFilterValue } from "../../Filters/DateFilter/types"; const SORT_ORDER = { lastSaved: "Last Saved", @@ -127,6 +128,140 @@ export const FieldSelectorFilters = ({ return fieldLabel; }; + const handleUpdateDateFilter = (dateFilter: DateFilterValue) => { + switch (dateFilter.type) { + case "daterange": { + const value = dateFilter.value as DateRangeFilterValue; + + onUpdateFilter({ + date: { + preset: null, + to: value.to, + from: value.from, + }, + }); + return; + } + + case "on": { + const value = dateFilter.value as string; + + onUpdateFilter({ + date: { + preset: null, + to: value, + from: value, + }, + }); + return; + } + case "before": { + const value = dateFilter.value as string; + + onUpdateFilter({ + date: { + preset: null, + to: value, + from: null, + }, + }); + return; + } + case "after": { + const value = dateFilter.value as string; + + onUpdateFilter({ + date: { + preset: null, + to: null, + from: value, + }, + }); + return; + } + case "preset": { + const value = dateFilter.value as string; + + onUpdateFilter({ + date: { + preset: value, + to: null, + from: null, + }, + }); + return; + } + + default: { + onUpdateFilter({ + date: { + preset: null, + to: null, + from: null, + }, + }); + return; + } + } + }; + + const activeDateFilter: DateFilterValue = useMemo(() => { + const isPreset = !!filters.date.preset; + const isBefore = !!filters.date.to && !!!filters.date.from; + const isAfter = !!filters.date.from && !!!filters.date.to; + const isOn = + !!filters.date.to && + !!filters.date.from && + filters.date.to === filters.date.from; + const isDateRange = + !!filters.date.to && + !!filters.date.from && + filters.date.to !== filters.date.from; + + if (isPreset) { + return { + type: "preset", + value: filters.date.preset, + }; + } + + if (isBefore) { + return { + type: "before", + value: filters.date.to, + }; + } + + if (isAfter) { + return { + type: "after", + value: filters.date.from, + }; + } + + if (isOn) { + return { + type: "on", + value: filters.date.from, + }; + } + + if (isDateRange) { + return { + type: "daterange", + value: { + from: filters.date.from, + to: filters.date.to, + }, + }; + } + + return { + type: "", + value: "", + }; + }, [filters.date]); + return ( onUpdateFilter({ date })} - value={filters.date} + onChange={(date) => handleUpdateDateFilter(date)} + value={activeDateFilter} /> { + if (filters.status === "published") { + return ( + item.item?.publishing?.publishAt && + !item.item?.scheduling?.publishAt + ); + } else if (filters.status === "scheduled") { + return item.item?.scheduling?.publishAt; + } else if (filters.status === "notPublished") { + return ( + !item.item?.publishing?.publishAt && + !item.item?.scheduling?.publishAt + ); + } + }); + } + + if (filters.user) { + _rows = _rows.filter( + (item) => item.item?.meta?.createdByUserZUID === filters.user + ); + } + + const dateFilterFn = getDateFilterFnByValues(filters.date); + if (dateFilterFn) { + _rows = _rows.filter((item) => { + if (!!item.item?.meta?.updatedAt) { + return dateFilterFn(item.item?.meta?.updatedAt); + } + + return false; + }); + } return _rows; }, [mappedRows, filterKeyword, relatedModelFields]); From eae27d73a666e77e112da873ecd0ff8fcb5b493d Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 22 Jan 2025 16:33:52 +0800 Subject: [PATCH 35/66] task: update logic for when to show the no results component --- .../FieldSelectorDialog/index.tsx | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index e1a2dc458..94f0ecfaf 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -99,11 +99,11 @@ export const FieldSelectorDialog = ({ }, { sortOrder: "lastSaved", - user: "", + user: null, date: { - preset: "", - from: "", - to: "", + preset: null, + from: null, + to: null, }, lang: null, status: null, @@ -524,6 +524,13 @@ export const FieldSelectorDialog = ({ const isLoading = isFetchingContentItems || isLoadingRelatedModel || isLoadingUsers; + const isFilteringResults = + !!filterKeyword || + !!filters.status || + !!filters.user || + !!filters.date.preset || + !!filters.date.from || + !!filters.date.to; return ( - {!rows?.length && !!filterKeyword ? ( + {!rows?.length && isFilteringResults ? ( { - setFilterKeyword(""); - if (!!searchField.current) { - searchField.current.querySelector("input").value = ""; - searchField.current.querySelector("input").focus(); + if (!!filterKeyword) { + setFilterKeyword(""); + if (!!searchField.current) { + searchField.current.querySelector("input").value = ""; + searchField.current.querySelector("input").focus(); + } } + + updateFilters({ + sortOrder: "lastSaved", + user: null, + date: { + preset: null, + from: null, + to: null, + }, + lang: langs.find((lang) => lang.default)?.ID, + status: null, + }); }} ignoreFilters hideBackButton From a7b9d5f3f6db875b7c907fb8322bb173f98115a9 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 23 Jan 2025 08:57:32 +0800 Subject: [PATCH 36/66] Simplify selection handling and filter deleted items. Refactored row selection logic to remove the `handleRowClick` function and replaced it with `onSelectionModelChange`. Ensured deleted items are filtered out from the selection and updated count calculations accordingly. This improves code clarity and maintains accurate selection states. --- .../FieldSelectorDialog/index.tsx | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index 94f0ecfaf..5df460d5f 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -24,7 +24,6 @@ import { debounce } from "lodash"; import { useDispatch, useSelector } from "react-redux"; import { FieldSelectorFilters, STATUS_FILTER } from "./FieldSelectorFilters"; -import { DateFilterValue } from "../../Filters/DateFilter"; import { useGetLangsQuery, useGetContentModelFieldsQuery, @@ -512,16 +511,6 @@ export const FieldSelectorDialog = ({ [setFilterKeyword] ); - const handleRowClick = (itemZUID: string) => { - if ((selectionModel as string[]).includes(itemZUID)) { - setSelectionModel( - (selectionModel as string[]).filter((id) => id !== itemZUID) - ); - } else { - setSelectionModel([...(selectionModel as string[]), itemZUID]); - } - }; - const isLoading = isFetchingContentItems || isLoadingRelatedModel || isLoadingUsers; const isFilteringResults = @@ -531,6 +520,9 @@ export const FieldSelectorDialog = ({ !!filters.date.preset || !!filters.date.from || !!filters.date.to; + const filteredSelectionModels = (selectionModel as string[])?.filter( + (ZUID) => !deletedItemZUIDs?.includes(ZUID) + ); return ( !deletedItemZUIDs?.includes(ZUID) - )?.length || 0 - } + selectedCount={filteredSelectionModels?.length || 0} onClose={onClose} onDeselectAll={() => setSelectionModel([])} onDone={() => onUpdateSelectedZUIDs(selectionModel as string[])} @@ -647,8 +635,10 @@ export const FieldSelectorDialog = ({ headerHeight={0} rowHeight={64} hideFooter - selectionModel={selectionModel} - onRowClick={(params) => handleRowClick(params.id as string)} + selectionModel={filteredSelectionModels} + onSelectionModelChange={(selectionModel) => + setSelectionModel([...deletedItemZUIDs, ...selectionModel]) + } sx={{ bgcolor: "background.paper", From 36a88ebce1d888e85b128a3cd843815dd279971e Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 23 Jan 2025 12:31:59 +0800 Subject: [PATCH 37/66] Add publish functionality and enhance ConfirmPublishModal Introduced the publish and schedule publish options, including the ability to handle immediate publishing via a modal. Enhanced ConfirmPublishModal with support for loading states and improved usability during publishing actions. --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 2 +- .../components}/ConfirmPublishModal.tsx | 31 ++++--- .../RelationalFieldBase/ActiveItem/index.tsx | 83 +++++++++++++++---- 3 files changed, 90 insertions(+), 26 deletions(-) rename src/{apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader => shell/components}/ConfirmPublishModal.tsx (86%) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 889814adc..60724a707 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -41,8 +41,8 @@ import { ContentItemWithDirtyAndPublishing, ContentModel, } from "../../../../../../../../shell/services/types"; -import { ConfirmPublishModal } from "./ConfirmPublishModal"; import { SchedulePublish } from "../../../../../../../../shell/components/SchedulePublish"; +import { ConfirmPublishModal } from "../../../../../../../../shell/components/ConfirmPublishModal"; const ITEM_STATES = { dirty: "dirty", diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ConfirmPublishModal.tsx b/src/shell/components/ConfirmPublishModal.tsx similarity index 86% rename from src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ConfirmPublishModal.tsx rename to src/shell/components/ConfirmPublishModal.tsx index b9266013d..54524774b 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ConfirmPublishModal.tsx +++ b/src/shell/components/ConfirmPublishModal.tsx @@ -1,23 +1,25 @@ +import { useRef } from "react"; import { + Box, + Button, + ButtonBaseActions, Dialog, - DialogTitle, - DialogContent, DialogActions, - Button, - Typography, - Box, + DialogContent, + DialogTitle, Stack, - ButtonBaseActions, + Typography, } from "@mui/material"; import CloudUploadRoundedIcon from "@mui/icons-material/CloudUploadRounded"; -import { useRef } from "react"; +import { LoadingButton } from "@mui/lab"; -type ConfirmPublishModal = { +export type ConfirmPublishModal = { contentTitle: string; onCancel: () => void; onConfirm: () => void; contentVersion: number; altText?: string; + isPublishing?: boolean; }; export const ConfirmPublishModal = ({ contentTitle, @@ -25,6 +27,7 @@ export const ConfirmPublishModal = ({ onConfirm, contentVersion, altText, + isPublishing, }: ConfirmPublishModal) => { const actionRef = useRef(null); const onEntered = () => actionRef?.current?.focusVisible(); @@ -63,10 +66,16 @@ export const ConfirmPublishModal = ({ - - + ); diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index e7adf52f3..f828d9e02 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -23,14 +23,20 @@ import { } from "@mui/icons-material"; import { useHistory } from "react-router"; import { useDrag, useDrop } from "react-dnd"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; -import { useGetContentModelFieldsQuery } from "../../../services/instance"; +import { + useCreateItemPublishingMutation, + useDeleteItemPublishingMutation, + useGetContentModelFieldsQuery, +} from "../../../services/instance"; import { ContentModel, ContentModelField } from "../../../services/types"; import { ActiveItemLoading } from "./ActiveItemLoading"; import { VersionCell } from "../FieldSelectorDialog/VersionCell"; import { AppState } from "../../../store/types"; import { useGetUsersQuery } from "../../../services/accounts"; +import { ConfirmPublishModal } from "../../ConfirmPublishModal"; +import { fetchItemPublishing } from "../../../store/content"; type ActiveItemProps = { itemZUID: string; @@ -53,13 +59,19 @@ export const ActiveItem = memo( }: ActiveItemProps) => { const [imageError, setImageError] = useState(false); const [anchorEl, setAnchorEl] = useState(null); + const [isPublishModalOpen, setIsPublishModalOpen] = useState(false); const history = useHistory(); + const dispatch = useDispatch(); const contentItems = useSelector((state: AppState) => state.content); const { data: relatedModelFields, isLoading: isLoadingRelatedModel } = useGetContentModelFieldsQuery(relatedModelData?.ZUID, { skip: !relatedModelData?.ZUID, }); const { data: users, isLoading: isLoadingUsers } = useGetUsersQuery(); + const [createPublishing, { isLoading: isPublishing }] = + useCreateItemPublishingMutation(); + const [deleteItemPublishing, { isLoading: isUnpublishing }] = + useDeleteItemPublishingMutation(); const [{ isDragging }, drag, preview] = useDrag({ type: "relationalItem", @@ -155,11 +167,36 @@ export const ActiveItem = memo( return null; }, [contentItem, imageFieldName]); + const handlePublish = async () => { + if (contentItem?.scheduling?.isScheduled) { + await deleteItemPublishing({ + modelZUID: relatedModelData?.ZUID, + itemZUID, + publishingZUID: contentItem?.scheduling?.ZUID, + }); + } + createPublishing({ + modelZUID: relatedModelData?.ZUID, + itemZUID, + body: { + version: contentItem?.meta.version, + publishAt: "now", + unpublishAt: "never", + }, + }).then(() => { + // Retain non rtk-query fetch of item publishing for legacy code + dispatch(fetchItemPublishing(relatedModelData?.ZUID, itemZUID)); + setIsPublishModalOpen(false); + }); + }; + const itemTitle = contentItem?.data[relatedFieldData?.name] || contentItem?.web?.metaTitle || contentItem?.web?.metaLinkText || itemZUID; + const isPublishable = + contentItem?.meta?.version > (contentItem?.publishing?.version || 0); if (isLoadingRelatedModel || isLoadingUsers) { return ; @@ -309,18 +346,27 @@ export const ActiveItem = memo( horizontal: "left", }} > - - - - - - - - - - - - + {isPublishable && ( + { + setAnchorEl(null); + setIsPublishModalOpen(true); + }} + > + + + + + + )} + {isPublishable && ( + + + + + + + )} @@ -347,6 +393,15 @@ export const ActiveItem = memo( )} + {isPublishModalOpen && ( + setIsPublishModalOpen(false)} + onConfirm={() => handlePublish()} + isPublishing={isPublishing || isUnpublishing} + /> + )} ); } From 7afe7d4c1e737953780ee2985d4ec83eca34c767 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 23 Jan 2025 13:08:25 +0800 Subject: [PATCH 38/66] Add support for scheduling content publishing Introduced a `SchedulePublish` modal to enable scheduling of content publishing. Updated state management and menu options to handle scheduling interactions. This enhances the user experience by allowing timed content releases. --- .../RelationalFieldBase/ActiveItem/index.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index f828d9e02..9f9ab67cd 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -37,6 +37,7 @@ import { AppState } from "../../../store/types"; import { useGetUsersQuery } from "../../../services/accounts"; import { ConfirmPublishModal } from "../../ConfirmPublishModal"; import { fetchItemPublishing } from "../../../store/content"; +import { SchedulePublish } from "../../SchedulePublish"; type ActiveItemProps = { itemZUID: string; @@ -60,6 +61,7 @@ export const ActiveItem = memo( const [imageError, setImageError] = useState(false); const [anchorEl, setAnchorEl] = useState(null); const [isPublishModalOpen, setIsPublishModalOpen] = useState(false); + const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false); const history = useHistory(); const dispatch = useDispatch(); const contentItems = useSelector((state: AppState) => state.content); @@ -187,6 +189,7 @@ export const ActiveItem = memo( // Retain non rtk-query fetch of item publishing for legacy code dispatch(fetchItemPublishing(relatedModelData?.ZUID, itemZUID)); setIsPublishModalOpen(false); + setIsScheduleModalOpen(false); }); }; @@ -360,7 +363,12 @@ export const ActiveItem = memo( )} {isPublishable && ( - + { + setAnchorEl(null); + setIsScheduleModalOpen(true); + }} + > @@ -402,6 +410,15 @@ export const ActiveItem = memo( isPublishing={isPublishing || isUnpublishing} /> )} + {isScheduleModalOpen && ( + handlePublish()} + onClose={() => setIsScheduleModalOpen(false)} + onScheduleSuccess={() => setIsScheduleModalOpen(false)} + onUnscheduleSuccess={() => setIsScheduleModalOpen(true)} + /> + )} ); } From ba96cb5f2b976810140668d376dc02466502e58b Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 23 Jan 2025 13:14:37 +0800 Subject: [PATCH 39/66] Refactor ActiveItem to handle deleted content items gracefully Updated `itemTitle` to display "(Deleted)" if the content item is missing. Refactored conditional rendering to improve readability and maintainability. This ensures better handling of deleted items and consistent UI behavior. --- .../RelationalFieldBase/ActiveItem/index.tsx | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index 9f9ab67cd..073627c32 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -193,11 +193,11 @@ export const ActiveItem = memo( }); }; - const itemTitle = - contentItem?.data[relatedFieldData?.name] || - contentItem?.web?.metaTitle || - contentItem?.web?.metaLinkText || - itemZUID; + const itemTitle = !!contentItem + ? contentItem?.data[relatedFieldData?.name] || + contentItem?.web?.metaTitle || + contentItem?.web?.metaLinkText + : `${itemZUID} (Deleted)`; const isPublishable = contentItem?.meta?.version > (contentItem?.publishing?.version || 0); @@ -309,31 +309,33 @@ export const ActiveItem = memo( )} - - {!!contentItem && ( + {!!contentItem && ( + - )} - - - history.push(`/content/${relatedModelData?.ZUID}/${itemZUID}`) - } - > - - - setAnchorEl(evt.currentTarget)} - > - - + + + history.push( + `/content/${relatedModelData?.ZUID}/${itemZUID}` + ) + } + > + + + setAnchorEl(evt.currentTarget)} + > + + + - + )} {!!anchorEl && ( Date: Thu, 23 Jan 2025 13:41:27 +0800 Subject: [PATCH 40/66] Add copy ZUID functionality to ActiveItem menu Introduced a new feature to copy the ZUID to the clipboard in `ActiveItem`. The feature provides visual feedback using a check icon and resets after 1.5 seconds. Additionally, commented-out unused field components in `Field.tsx` were adjusted for clarity. --- .../src/app/components/Editor/Field/Field.tsx | 4 ++++ .../RelationalFieldBase/ActiveItem/index.tsx | 22 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx index eea7ec272..5d1bfd130 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx @@ -790,6 +790,7 @@ export const Field = ({ relatedFieldZUID={relatedFieldZUID} onChange={onChange} /> + {/** !!error)} /> + */} ); @@ -859,6 +861,7 @@ export const Field = ({ relatedFieldZUID={relatedFieldZUID} onChange={onChange} /> + {/** !!error)} /> + */} ); diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index 073627c32..5c5cf8fb9 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -20,6 +20,7 @@ import { LanguageRounded, WidgetsRounded, CloseRounded, + CheckRounded, } from "@mui/icons-material"; import { useHistory } from "react-router"; import { useDrag, useDrop } from "react-dnd"; @@ -62,6 +63,7 @@ export const ActiveItem = memo( const [anchorEl, setAnchorEl] = useState(null); const [isPublishModalOpen, setIsPublishModalOpen] = useState(false); const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false); + const [isCopied, setIsCopied] = useState(false); const history = useHistory(); const dispatch = useDispatch(); const contentItems = useSelector((state: AppState) => state.content); @@ -193,6 +195,22 @@ export const ActiveItem = memo( }); }; + const handleCopyZUID = () => { + if (isCopied) return; + + navigator?.clipboard + ?.writeText(itemZUID) + .then(() => { + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 1500); + }) + .catch((err) => { + console.error(err); + }); + }; + const itemTitle = !!contentItem ? contentItem?.data[relatedFieldData?.name] || contentItem?.web?.metaTitle || @@ -389,9 +407,9 @@ export const ActiveItem = memo( - + - + {isCopied ? : } From 46261d7bcf71b30c10724b58a63f1afce5b002d4 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 23 Jan 2025 15:32:57 +0800 Subject: [PATCH 41/66] Add dynamic preview URLs for Draft and Production This commit introduces dynamic URLs for Draft and Production previews based on content item versions. It utilizes the `useDomain` hook and conditionally generates URLs with a preview lock password when available. Additionally, unused static preview menu items have been replaced with dynamically generated ones. --- .../RelationalFieldBase/ActiveItem/index.tsx | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index 5c5cf8fb9..da254639f 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -39,6 +39,7 @@ import { useGetUsersQuery } from "../../../services/accounts"; import { ConfirmPublishModal } from "../../ConfirmPublishModal"; import { fetchItemPublishing } from "../../../store/content"; import { SchedulePublish } from "../../SchedulePublish"; +import { useDomain } from "../../../hooks/use-domain"; type ActiveItemProps = { itemZUID: string; @@ -66,7 +67,15 @@ export const ActiveItem = memo( const [isCopied, setIsCopied] = useState(false); const history = useHistory(); const dispatch = useDispatch(); + const domain = useDomain(); const contentItems = useSelector((state: AppState) => state.content); + const instance = useSelector((state: AppState) => state.instance); + const previewLock = useSelector((state: AppState) => + state.settings.instance.find( + (setting: any) => + setting.key === "preview_lock_password" && setting.value + ) + ); const { data: relatedModelFields, isLoading: isLoadingRelatedModel } = useGetContentModelFieldsQuery(relatedModelData?.ZUID, { skip: !relatedModelData?.ZUID, @@ -395,18 +404,49 @@ export const ActiveItem = memo( )} - - - - - - - - - - - - + {!!contentItem?.meta?.version && ( + { + // @ts-expect-error Config not typed + let devUrl = `${CONFIG.URL_PREVIEW_PROTOCOL}${instance.randomHashID}${CONFIG.URL_PREVIEW}${contentItem?.web?.path}`; + + if (previewLock) { + devUrl = `${devUrl}?zpw=${previewLock.value}`; + } + + setAnchorEl(null); + window.open(devUrl, "_blank"); + }} + > + + + + + + )} + {!!contentItem?.publishing?.version && ( + { + const prodUrl = + domain + contentItem?.web?.pathPart !== "zesty_home" + ? contentItem?.web?.path + : ""; + + setAnchorEl(null); + window.open(prodUrl, "_blank"); + }} + > + + + + + + )} + {isCopied ? : } From 806e9a1486b03c306d0184bcee0f8996ad50fb3c Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 28 Jan 2025 09:37:35 +0800 Subject: [PATCH 42/66] Add onRemoveCard functionality to ActiveItem component --- .../components/RelationalFieldBase/ActiveItem/index.tsx | 4 +++- src/shell/components/RelationalFieldBase/index.tsx | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index da254639f..1da8d4a7e 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -48,6 +48,7 @@ type ActiveItemProps = { relatedModelData: ContentModel; onMoveCard: (draggedItemZUID: string, dropIndex: number) => void; onDropCard: () => void; + onRemoveCard: (itemZUID: string) => void; draggable?: boolean; }; export const ActiveItem = memo( @@ -58,6 +59,7 @@ export const ActiveItem = memo( relatedModelData, onMoveCard, onDropCard, + onRemoveCard, draggable, }: ActiveItemProps) => { const [imageError, setImageError] = useState(false); @@ -453,7 +455,7 @@ export const ActiveItem = memo( - + onRemoveCard(itemZUID)}> diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index 93c318830..f803db04c 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -84,6 +84,15 @@ export const RelationalFieldBase = ({ )} onMoveCard={handleMoveCard} onDropCard={handleReorder} + onRemoveCard={(itemZUID) => { + setItemZUIDs((prev) => + prev.filter((zuid) => zuid !== itemZUID) + ); + onChange( + itemZUIDs.filter((zuid) => zuid !== itemZUID).join(","), + name + ); + }} draggable={multiselect} /> ))} From e9bfa571d551b07ee778f49640852af0b98c632c Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 28 Jan 2025 09:43:59 +0800 Subject: [PATCH 43/66] Add loading state handling to RelationalFieldBase component --- .../components/RelationalFieldBase/index.tsx | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index f803db04c..efdfd299c 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -16,6 +16,7 @@ import { useGetContentModelFieldsQuery, } from "../../services/instance"; import { fetchItems } from "../../store/content"; +import { ActiveItemLoading } from "./ActiveItem/ActiveItemLoading"; type RelationalFieldBaseProps = { name: string; @@ -38,13 +39,14 @@ export const RelationalFieldBase = ({ const [showAll, setShowAll] = useState(false); const [anchorEl, setAnchorEl] = useState(null); - const { data: modelData } = useGetContentModelQuery(relatedModelZUID, { - skip: !relatedModelZUID, - }); - const { data: modelFields } = useGetContentModelFieldsQuery( - relatedModelZUID, - { skip: !relatedModelZUID } - ); + const { data: modelData, isLoading: isLoadingModelData } = + useGetContentModelQuery(relatedModelZUID, { + skip: !relatedModelZUID, + }); + const { data: modelFields, isLoading: isLoadingModelFields } = + useGetContentModelFieldsQuery(relatedModelZUID, { + skip: !relatedModelZUID, + }); useEffect(() => { if (!!relatedModelZUID) { @@ -72,31 +74,37 @@ export const RelationalFieldBase = ({ return ( - - {itemZUIDs?.slice(0, showAll ? undefined : 5)?.map((val, index) => ( - field.ZUID === relatedFieldZUID - )} - onMoveCard={handleMoveCard} - onDropCard={handleReorder} - onRemoveCard={(itemZUID) => { - setItemZUIDs((prev) => - prev.filter((zuid) => zuid !== itemZUID) - ); - onChange( - itemZUIDs.filter((zuid) => zuid !== itemZUID).join(","), - name - ); - }} - draggable={multiselect} - /> - ))} - + {isLoadingModelData || isLoadingModelFields ? ( + [...Array(multiselect ? 5 : 1)].map((_, index) => ( + + )) + ) : ( + + {itemZUIDs?.slice(0, showAll ? undefined : 5)?.map((val, index) => ( + field.ZUID === relatedFieldZUID + )} + onMoveCard={handleMoveCard} + onDropCard={handleReorder} + onRemoveCard={(itemZUID) => { + setItemZUIDs((prev) => + prev.filter((zuid) => zuid !== itemZUID) + ); + onChange( + itemZUIDs.filter((zuid) => zuid !== itemZUID).join(","), + name + ); + }} + draggable={multiselect} + /> + ))} + + )} {itemZUIDs?.length > 5 && ( From a135ab4fc56b89f5dbf8f82e9e214c2b42ceca07 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 28 Jan 2025 15:44:56 +0800 Subject: [PATCH 44/66] Add unpublished related items list to ConfirmPublishModal --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 81 ++++++++++++++++++- .../ItemEditHeader/UnpublishedRelatedItem.tsx | 30 +++++++ src/shell/components/ConfirmPublishModal.tsx | 3 + 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/UnpublishedRelatedItem.tsx diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 60724a707..2279fc300 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -8,15 +8,18 @@ import { Menu, MenuItem, ListItemIcon, + Stack, + List, } from "@mui/material"; import { useCreateItemPublishingMutation, useDeleteItemPublishingMutation, useGetAuditsQuery, + useGetContentModelFieldsQuery, useGetItemPublishingsQuery, } from "../../../../../../../../shell/services/instance"; import { useHistory, useParams } from "react-router"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { SaveRounded, @@ -43,6 +46,7 @@ import { } from "../../../../../../../../shell/services/types"; import { SchedulePublish } from "../../../../../../../../shell/components/SchedulePublish"; import { ConfirmPublishModal } from "../../../../../../../../shell/components/ConfirmPublishModal"; +import { UnpublishedRelatedItem } from "./UnpublishedRelatedItem"; const ITEM_STATES = { dirty: "dirty", @@ -82,9 +86,13 @@ export const ItemEditHeaderActions = ({ (state: AppState) => state.content[itemZUID] as ContentItemWithDirtyAndPublishing ); + const items = useSelector((state: AppState) => state.content); const model = useSelector( (state: AppState) => state.models[modelZUID] ) as ContentModel; + const { data: fields } = useGetContentModelFieldsQuery(modelZUID, { + skip: !modelZUID, + }); const { data: users } = useGetUsersQuery(); const { data: itemAudit } = useGetAuditsQuery({ affectedZUID: itemZUID, @@ -121,6 +129,56 @@ export const ItemEditHeaderActions = ({ } }); + const unpublishedRelatedItems = useMemo(() => { + if (!fields || !item.data || !items) return []; + + const relatedFieldZUIDs = Object.entries(item.data)?.reduce( + (acc: Record, [key, value]) => { + const field = fields.find((field) => field.name === key); + + if ( + !!value && + (field?.datatype === "one_to_many" || + field?.datatype === "one_to_one") + ) { + acc[field.relatedModelZUID] = [ + ...(acc[field.relatedModelZUID] || []), + ...(value as string)?.split(","), + ]; + } + + return acc; + }, + {} + ); + + const unpublishedRelatedItems = Object.entries(relatedFieldZUIDs) + ?.map(([modelZUID, itemZUIDs]) => { + const relatedItems = itemZUIDs?.map((ZUID) => { + const item = Object.values(items)?.find( + (item) => + item.meta.ZUID === ZUID && + item.meta.contentModelZUID === modelZUID + ); + + if (!!item) { + const draftVersion = item?.meta?.version; + const publishedVersion = item?.publishing?.version || 0; + + if (draftVersion > publishedVersion) { + return item; + } + } + }); + + return relatedItems; + }) + ?.flat() + ?.filter((item) => !!item); + + return unpublishedRelatedItems; + }, [fields, item, items]); + const itemState = (() => { if (item?.dirty) { return ITEM_STATES.dirty; @@ -531,7 +589,26 @@ export const ItemEditHeaderActions = ({ handlePublish(); }} altText={model?.type === "block" && "Variant"} - /> + > + {unpublishedRelatedItems?.length > 0 && ( + + + Also publish related items + + + This will publish all items selected in the list below + + + {unpublishedRelatedItems.map((item) => ( + + ))} + + + )} + )} ); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/UnpublishedRelatedItem.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/UnpublishedRelatedItem.tsx new file mode 100644 index 000000000..26cad3f28 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/UnpublishedRelatedItem.tsx @@ -0,0 +1,30 @@ +import { memo } from "react"; +import { + ListItem, + ListItemButton, + ListItemText, + Box, + Checkbox, +} from "@mui/material"; +import { ContentItemWithDirtyAndPublishing } from "../../../../../../../../shell/services/types"; + +type UnpublishedRelatedItemProps = { + contentItem: ContentItemWithDirtyAndPublishing; +}; +export const UnpublishedRelatedItem = memo( + ({ contentItem }: UnpublishedRelatedItemProps) => { + return ( + + + + + + + ); + } +); + +UnpublishedRelatedItem.displayName = "UnpublishedRelatedItem"; diff --git a/src/shell/components/ConfirmPublishModal.tsx b/src/shell/components/ConfirmPublishModal.tsx index 54524774b..cbfeaf64c 100644 --- a/src/shell/components/ConfirmPublishModal.tsx +++ b/src/shell/components/ConfirmPublishModal.tsx @@ -20,6 +20,7 @@ export type ConfirmPublishModal = { contentVersion: number; altText?: string; isPublishing?: boolean; + children?: JSX.Element; }; export const ConfirmPublishModal = ({ contentTitle, @@ -28,6 +29,7 @@ export const ConfirmPublishModal = ({ contentVersion, altText, isPublishing, + children, }: ConfirmPublishModal) => { const actionRef = useRef(null); const onEntered = () => actionRef?.current?.focusVisible(); @@ -64,6 +66,7 @@ export const ConfirmPublishModal = ({ {altText ? altText?.toLowerCase() : "item"} available on all of your platforms. You can always unpublish this item later if needed. + {children} From 60ad729d5e9e91a1123e12611d19ba5960e03ef0 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 30 Jan 2025 16:48:08 +0800 Subject: [PATCH 48/66] Add support for publishing multiple related items in ItemEditHeaderActions --- .../ItemEditHeader/ItemEditHeaderActions.tsx | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 218199975..4fc300e27 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -13,6 +13,7 @@ import { } from "@mui/material"; import { useCreateItemPublishingMutation, + useCreateItemsPublishingMutation, useDeleteItemPublishingMutation, useGetAuditsQuery, useGetContentModelFieldsQuery, @@ -34,7 +35,10 @@ import { import { useDispatch, useSelector } from "react-redux"; import { AppState } from "../../../../../../../../shell/store/types"; import { useMetaKey } from "../../../../../../../../shell/hooks/useMetaKey"; -import { fetchItemPublishing } from "../../../../../../../../shell/store/content"; +import { + fetchItemPublishing, + fetchItemPublishings, +} from "../../../../../../../../shell/store/content"; import { LoadingButton } from "@mui/lab"; import { useGetUsersQuery } from "../../../../../../../../shell/services/accounts"; import { formatDate } from "../../../../../../../../utility/formatDate"; @@ -87,6 +91,7 @@ export const ItemEditHeaderActions = ({ const [relatedItemsToPublish, setRelatedItemsToPublish] = useState< ContentItem[] >([]); + const [isPublishing, setIsPublishing] = useState(false); const item = useSelector( (state: AppState) => state.content[itemZUID] as ContentItemWithDirtyAndPublishing @@ -102,8 +107,7 @@ export const ItemEditHeaderActions = ({ const { data: itemAudit } = useGetAuditsQuery({ affectedZUID: itemZUID, }); - const [createPublishing, { isLoading: publishing }] = - useCreateItemPublishingMutation(); + const [createPublishing] = useCreateItemPublishingMutation(); const [deleteItemPublishing, { isLoading: unpublishing }] = useDeleteItemPublishingMutation(); const lastItemUpdateAudit = itemAudit?.find( @@ -217,6 +221,7 @@ export const ItemEditHeaderActions = ({ })(); const handlePublish = async () => { + setIsPublishing(true); // If item is scheduled, delete the scheduled publishing first if (itemState === ITEM_STATES.scheduled) { await deleteItemPublishing({ @@ -225,18 +230,38 @@ export const ItemEditHeaderActions = ({ publishingZUID: item?.scheduling?.ZUID, }); } - createPublishing({ - modelZUID, - itemZUID, - body: { - version: item?.meta.version, - publishAt: "now", - unpublishAt: "never", - }, - }).then(() => { - // Retain non rtk-query fetch of item publishing for legacy code - dispatch(fetchItemPublishing(modelZUID, itemZUID)); - }); + + // FIXME: Handle items that are currently scheduled + Promise.allSettled([ + createPublishing({ + modelZUID, + itemZUID, + body: { + version: item?.meta.version, + publishAt: "now", + unpublishAt: "never", + }, + }), + relatedItemsToPublish.map((item) => { + return createPublishing({ + modelZUID: item.meta.contentModelZUID, + itemZUID: item.meta.ZUID, + body: { + version: item.meta.version, + publishAt: "now", + unpublishAt: "never", + }, + }); + }), + ]) + .then(() => { + // Retain non rtk-query fetch of item publishing for legacy code + dispatch(fetchItemPublishings()); + }) + .finally(() => { + setIsPublishing(false); + setIsConfirmPublishModalOpen(false); + }); }; const handleUnpublish = async () => { @@ -403,7 +428,7 @@ export const ItemEditHeaderActions = ({ setIsConfirmPublishModalOpen(true); } }} - loading={publishing || saving || isFetching} + loading={isPublishing || saving || isFetching} color="success" variant="contained" id="PublishButton" @@ -421,7 +446,7 @@ export const ItemEditHeaderActions = ({ onClick={(e) => { setPublishMenu(e.currentTarget); }} - disabled={publishing || saving || isFetching} + disabled={isPublishing || saving || isFetching} data-cy="PublishMenuButton" > @@ -505,7 +530,7 @@ export const ItemEditHeaderActions = ({ onClick={() => { setIsConfirmPublishModalOpen(true); }} - loading={publishing || publishAfterSave || isFetching} + loading={isPublishing || publishAfterSave || isFetching} color="success" variant="contained" id="PublishButton" @@ -523,7 +548,7 @@ export const ItemEditHeaderActions = ({ onClick={(e) => { setPublishMenu(e.currentTarget); }} - disabled={publishing || publishAfterSave || isFetching} + disabled={isPublishing || publishAfterSave || isFetching} data-cy="PublishMenuButton" > @@ -609,12 +634,13 @@ export const ItemEditHeaderActions = ({ setPublishAfterUnschedule(false); }} onConfirm={() => { - setIsConfirmPublishModalOpen(false); + // setIsConfirmPublishModalOpen(false); setPublishAfterUnschedule(false); handlePublish(); }} altText={model?.type === "block" && "Variant"} relatedItemsToPublishCount={relatedItemsToPublish.length} + isPublishing={isPublishing} > {unpublishedRelatedItems?.length > 0 && ( From 76aab65a216ec16c59c56c1821a77ae8a7224a7e Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 30 Jan 2025 17:22:39 +0800 Subject: [PATCH 49/66] Add scheduling check to draft version comparison in ItemEditHeaderActions --- .../components/ItemEditHeader/ItemEditHeaderActions.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 4fc300e27..d7a05a1b9 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -185,7 +185,10 @@ export const ItemEditHeaderActions = ({ const draftVersion = item?.meta?.version; const publishedVersion = item?.publishing?.version || 0; - if (draftVersion > publishedVersion) { + if ( + draftVersion > publishedVersion && + !item?.scheduling?.isScheduled + ) { return { ...item, relatedFieldZUID, @@ -231,7 +234,6 @@ export const ItemEditHeaderActions = ({ }); } - // FIXME: Handle items that are currently scheduled Promise.allSettled([ createPublishing({ modelZUID, From 6cdac8a262eca711aad68e790ef5b130fd112c6d Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Fri, 31 Jan 2025 13:31:18 +0800 Subject: [PATCH 50/66] Add validation for 'one_to_many' field to limit item selection to 255 characters --- .../src/app/components/Editor/Editor.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/apps/content-editor/src/app/components/Editor/Editor.js b/src/apps/content-editor/src/app/components/Editor/Editor.js index e172e1fbb..02059a80b 100644 --- a/src/apps/content-editor/src/app/components/Editor/Editor.js +++ b/src/apps/content-editor/src/app/components/Editor/Editor.js @@ -209,6 +209,18 @@ export default memo(function Editor({ }; } + if (field.datatype === "one_to_many") { + // Value is stored as string in DB with max char limit of 255. + // This means users can only add up to 12 item zuids + errors[name] = { + ...(errors[name] ?? []), + CUSTOM_ERROR: + !!value && value?.length > 255 + ? "Cannot save field. Please reduce the total number of items selected." + : "", + }; + } + onUpdateFieldErrors(errors); // Always dispatch the data update From 1d7fb2bfee60fc51803aa23332d9f9db7289e8c5 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Fri, 31 Jan 2025 13:50:11 +0800 Subject: [PATCH 51/66] Refactor selection model handling in FieldSelectorDialog to enforce single selection when multiselect is disabled --- .../FieldSelectorDialog/index.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index 5df460d5f..1678851b9 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -636,9 +636,18 @@ export const FieldSelectorDialog = ({ rowHeight={64} hideFooter selectionModel={filteredSelectionModels} - onSelectionModelChange={(selectionModel) => - setSelectionModel([...deletedItemZUIDs, ...selectionModel]) - } + onSelectionModelChange={(newSelectionModel) => { + let _newSelectionModel = newSelectionModel as string[]; + + if (!multiselect && _newSelectionModel?.length > 1) { + _newSelectionModel = [_newSelectionModel[0]]; + } + + setSelectionModel([ + ...deletedItemZUIDs, + ..._newSelectionModel, + ]); + }} sx={{ bgcolor: "background.paper", From dc2c6cd5c93df3a4c55ee70897bd618c856d7a64 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Fri, 31 Jan 2025 14:00:54 +0800 Subject: [PATCH 52/66] Refactor ActiveItem component to disable edit button when contentItem is not available and conditionally render Copy ZUID menu item --- .../RelationalFieldBase/ActiveItem/index.tsx | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx index 1da8d4a7e..474bc04cb 100644 --- a/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx +++ b/src/shell/components/RelationalFieldBase/ActiveItem/index.tsx @@ -338,33 +338,33 @@ export const ActiveItem = memo( )} - {!!contentItem && ( - + + + {!!contentItem && ( - - - history.push( - `/content/${relatedModelData?.ZUID}/${itemZUID}` - ) - } - > - - - setAnchorEl(evt.currentTarget)} - > - - - + )} + + + history.push(`/content/${relatedModelData?.ZUID}/${itemZUID}`) + } + disabled={!contentItem} + > + + + setAnchorEl(evt.currentTarget)} + > + + - )} + {!!anchorEl && ( )} - - - {isCopied ? : } - - - + {!!contentItem && ( + + + {isCopied ? : } + + + + )} onRemoveCard(itemZUID)}> From 087863f55334778358fb2ce96b7581d229a5db3a Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 4 Feb 2025 13:13:33 +0800 Subject: [PATCH 53/66] Add Cypress tests for one-to-one and one-to-many relational fields; enhance accessibility with data-cy attributes --- cypress/e2e/content/content.spec.js | 107 ++++++++++++++++++ src/shell/components/ConfirmPublishModal.tsx | 1 + .../RelationalFieldBase/ActiveItem/index.tsx | 9 +- .../FieldSelectorDialog/DialogHeader.tsx | 3 +- .../components/RelationalFieldBase/index.tsx | 2 + .../components/SchedulePublish/index.tsx | 1 + 6 files changed, 121 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/content/content.spec.js b/cypress/e2e/content/content.spec.js index 27bf6ca53..84a31e224 100644 --- a/cypress/e2e/content/content.spec.js +++ b/cypress/e2e/content/content.spec.js @@ -430,4 +430,111 @@ describe("Content Specs", () => { cy.getBySelector("BlockFieldVariantPreview").should("exist"); }); }); + + context("One to one field", () => { + before(() => { + cy.waitOn("/v1/content/models*", () => { + cy.visit("/content/6-556370-8sh47g/7-b939a4-457q19"); + }); + + cy.intercept({ method: "GET", url: "**/items*" }).as("fetchItems"); + cy.intercept({ method: "GET", url: "**/models*" }).as("fetchModels"); + cy.intercept({ method: "GET", url: "**/fields*" }).as("fetchFields"); + + cy.wait("@fetchFields"); + cy.getBySelector("DuoModeToggle").click(); + }); + + it("can only select/add one item", () => { + cy.get("#12-edee00-6zb866 [data-cy='add-relational-item-button']").click({ + force: true, + }); + + cy.wait("@fetchItems"); + + cy.get(".MuiDataGrid-row").first().find("input").click(); + cy.get(".MuiDataGrid-row").eq(1).find("input").click(); + + cy.getBySelector("selected-count").contains("1 / 1 selected"); + cy.getBySelector("done-selecting-item-button").click(); + cy.get("#12-edee00-6zb866 [data-cy='active-relational-item']").should( + "have.length", + 1 + ); + }); + + it("can publish an item", () => { + cy.get( + "#12-edee00-6zb866 [data-cy='active-relational-item-more-button']" + ).click(); + cy.getBySelector("active-relational-item-publish-now-button").click(); + cy.getBySelector("ConfirmPublishModal").should("exist"); + cy.getBySelector("CancelPublishButton").click(); + }); + + it("can schedule publish an item", () => { + cy.get( + "#12-edee00-6zb866 [data-cy='active-relational-item-more-button']" + ).click(); + cy.getBySelector( + "active-relational-item-schedule-publish-button" + ).click(); + cy.getBySelector("SchedulePublishModal").should("exist"); + cy.getBySelector("CancelSchedulePublishButton").click(); + }); + + it("can remove the selected item", () => { + cy.get( + "#12-edee00-6zb866 [data-cy='active-relational-item-more-button']" + ).click(); + cy.getBySelector("active-relational-item-remove-item-button").click(); + cy.get("#12-edee00-6zb866 [data-cy='active-relational-item']").should( + "not.exist" + ); + }); + }); + + context("One to many field", () => { + before(() => { + cy.waitOn("/v1/content/models*", () => { + cy.visit("/content/6-556370-8sh47g/7-b939a4-457q19"); + }); + + cy.intercept({ method: "GET", url: "**/items*" }).as("fetchItems"); + cy.intercept({ method: "GET", url: "**/models*" }).as("fetchModels"); + cy.intercept({ method: "GET", url: "**/fields*" }).as("fetchFields"); + + cy.wait("@fetchFields"); + cy.getBySelector("DuoModeToggle").click(); + }); + + it("can add multiple items", () => { + cy.get("#12-269a28-1bkm34 [data-cy='add-relational-item-button']").click({ + force: true, + }); + + cy.wait("@fetchItems"); + + [...Array(3)].forEach((_, i) => { + cy.get(".MuiDataGrid-row").eq(i).find("input").click(); + }); + cy.getBySelector("selected-count").contains("3 selected"); + cy.getBySelector("done-selecting-item-button").click(); + cy.get("#12-269a28-1bkm34 [data-cy='active-relational-item']").should( + "have.length", + 3 + ); + }); + + it("can remove the selected item", () => { + cy.get("#12-269a28-1bkm34 [data-cy='active-relational-item-more-button']") + .first() + .click(); + cy.getBySelector("active-relational-item-remove-item-button").click(); + cy.get("#12-269a28-1bkm34 [data-cy='active-relational-item']").should( + "have.length", + 2 + ); + }); + }); }); diff --git a/src/shell/components/ConfirmPublishModal.tsx b/src/shell/components/ConfirmPublishModal.tsx index 79af57b4a..ef794fcf3 100644 --- a/src/shell/components/ConfirmPublishModal.tsx +++ b/src/shell/components/ConfirmPublishModal.tsx @@ -73,6 +73,7 @@ export const ConfirmPublishModal = ({ )} {(multiselect || (!multiselect && !value)) && ( - + + + {multiselect && ( + + )} + )} {!!anchorEl && ( Date: Wed, 5 Feb 2025 09:03:38 +0800 Subject: [PATCH 55/66] Load content editor in dialog --- .../CreateNewItemDialog.tsx | 35 +++++++++++++++++++ .../components/RelationalFieldBase/index.tsx | 12 ++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/shell/components/RelationalFieldBase/CreateNewItemDialog.tsx diff --git a/src/shell/components/RelationalFieldBase/CreateNewItemDialog.tsx b/src/shell/components/RelationalFieldBase/CreateNewItemDialog.tsx new file mode 100644 index 000000000..d221c4c99 --- /dev/null +++ b/src/shell/components/RelationalFieldBase/CreateNewItemDialog.tsx @@ -0,0 +1,35 @@ +import { MemoryRouter } from "react-router"; +import { Dialog } from "@mui/material"; +import { createPortal } from "react-dom"; +import ContentEditor from "../../../apps/content-editor/src"; + +type CreateNewItemDialogProps = { + modelZUID: string; + onItemCreated: () => void; + onClose: () => void; +}; +export const CreateNewItemDialog = ({ + modelZUID, + onItemCreated, + onClose, +}: CreateNewItemDialogProps) => { + return createPortal( + + + + + , + document.getElementById("modalMount") + ); +}; diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index 502e1c37f..20215b712 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -18,6 +18,7 @@ import { } from "../../services/instance"; import { fetchItems } from "../../store/content"; import { ActiveItemLoading } from "./ActiveItem/ActiveItemLoading"; +import { CreateNewItemDialog } from "./CreateNewItemDialog"; type RelationalFieldBaseProps = { name: string; @@ -39,6 +40,8 @@ export const RelationalFieldBase = ({ const [itemZUIDs, setItemZUIDs] = useState(value?.split(",") || []); const [showAll, setShowAll] = useState(false); const [anchorEl, setAnchorEl] = useState(null); + const [isCreateNewItemDialogOpen, setIsCreateNewItemDialogOpen] = + useState(false); const { data: modelData, isLoading: isLoadingModelData } = useGetContentModelQuery(relatedModelZUID, { @@ -146,7 +149,7 @@ export const RelationalFieldBase = ({ size="large" startIcon={} fullWidth - // onClick={(evt) => setAnchorEl(evt.currentTarget)} + onClick={() => setIsCreateNewItemDialogOpen(true)} disabled={isLoadingModelData || isLoadingModelFields} > Create & Add New {modelData?.label} @@ -174,6 +177,13 @@ export const RelationalFieldBase = ({ }} /> )} + {isCreateNewItemDialogOpen && ( + {}} + onClose={() => setIsCreateNewItemDialogOpen(false)} + /> + )} ); }; From 559a4b1496f0b6a54d8ed652a2ce14a1b879ef88 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 5 Feb 2025 12:16:16 +0800 Subject: [PATCH 56/66] Enhance Content Editor and Header components for dialog support; add close functionality and conditional rendering --- .../content-editor/src/app/ContentEditor.js | 21 +++-- .../src/app/views/ItemCreate/Header.tsx | 80 ++++++++++++------- .../CreateNewItemDialog.tsx | 26 +++++- 3 files changed, 87 insertions(+), 40 deletions(-) diff --git a/src/apps/content-editor/src/app/ContentEditor.js b/src/apps/content-editor/src/app/ContentEditor.js index 3dd51afe7..b8e9b77da 100644 --- a/src/apps/content-editor/src/app/ContentEditor.js +++ b/src/apps/content-editor/src/app/ContentEditor.js @@ -32,6 +32,7 @@ import { ResizableContainer } from "../../../../shell/components/ResizeableConta import { StagedChangesProvider } from "./views/ItemList/StagedChangesContext"; import { SelectedItemsProvider } from "./views/ItemList/SelectedItemsContext"; import { TableSortProvider } from "./views/ItemList/TableSortProvider"; +import { useParams } from "../../../../shell/hooks/useParams"; // Makes sure that other apps using legacy theme does not get affected with the palette export let customTheme = createTheme(legacyTheme, { @@ -101,6 +102,7 @@ export let customTheme = createTheme(legacyTheme, { export default function ContentEditor() { const navContent = useSelector((state) => state.navContent); const dispatch = useDispatch(); + const [params] = useParams(); const [loading, setLoading] = useState(true); @@ -133,14 +135,17 @@ export default function ContentEditor() { ) : (
- - - + {params.get("isDialog") !== "true" && ( + + + + )} +
diff --git a/src/apps/content-editor/src/app/views/ItemCreate/Header.tsx b/src/apps/content-editor/src/app/views/ItemCreate/Header.tsx index 92e15be99..f382a8438 100644 --- a/src/apps/content-editor/src/app/views/ItemCreate/Header.tsx +++ b/src/apps/content-editor/src/app/views/ItemCreate/Header.tsx @@ -11,18 +11,22 @@ import { MenuItem, ListItemIcon, ListItemText, + IconButton, } from "@mui/material"; import ArrowDropDownRoundedIcon from "@mui/icons-material/ArrowDropDownRounded"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import SaveRoundedIcon from "@mui/icons-material/SaveRounded"; import CloudUploadRoundedIcon from "@mui/icons-material/CloudUploadRounded"; import CalendarTodayRoundedIcon from "@mui/icons-material/CalendarTodayRounded"; +import { CloseRounded } from "@mui/icons-material"; import { theme } from "@zesty-io/material"; import { useMetaKey } from "../../../../../../shell/hooks/useMetaKey"; import { ContentModel } from "../../../../../../shell/services/types"; import { ContentBreadcrumbs } from "../../components/ContentBreadcrumbs"; import { ActionAfterSave } from "./ItemCreate"; +import { useParams } from "../../../../../../shell/hooks/useParams"; +import { CREATE_NEW_ITEM_DIALOG_EVENTS } from "../../../../../../shell/components/RelationalFieldBase/CreateNewItemDialog"; type DropdownMenuType = "default" | "addNew"; const DropdownMenu: Record> = { @@ -43,11 +47,13 @@ interface Props { isDirty: boolean; } export const Header = ({ model, onSave, isLoading, isDirty }: Props) => { + const [params] = useParams(); const [dropdownMenuType, setDropdownMenuType] = useState(null); const [anchorEl, setAnchorEl] = useState(null); const metaShortcut = useMetaKey("s", onSave); + const isRenderedAsDialog = params.get("isDialog") === "true"; return ( @@ -84,38 +90,40 @@ export const Header = ({ model, onSave, isLoading, isDirty }: Props) => { - - } - onClick={() => { - onSave("addNew"); - }} - loading={isLoading} + {!isRenderedAsDialog && ( + - Create & Add New - - - + } + onClick={() => { + onSave("addNew"); + }} + loading={isLoading} + variant="outlined" + > + Create & Add New + + + + )} { + {isRenderedAsDialog && ( + { + window.dispatchEvent( + new CustomEvent(CREATE_NEW_ITEM_DIALOG_EVENTS.CLOSE) + ); + }} + > + + + )} void; @@ -13,8 +19,24 @@ export const CreateNewItemDialog = ({ onItemCreated, onClose, }: CreateNewItemDialogProps) => { + useEffect(() => { + window.addEventListener(CREATE_NEW_ITEM_DIALOG_EVENTS.CLOSE, onClose); + window.addEventListener( + CREATE_NEW_ITEM_DIALOG_EVENTS.ITEM_CREATED, + onItemCreated + ); + + return () => { + window.removeEventListener(CREATE_NEW_ITEM_DIALOG_EVENTS.CLOSE, onClose); + window.addEventListener( + CREATE_NEW_ITEM_DIALOG_EVENTS.ITEM_CREATED, + onItemCreated + ); + }; + }, []); + return createPortal( - + Date: Wed, 5 Feb 2025 14:13:53 +0800 Subject: [PATCH 57/66] Optimize fields --- .../src/app/components/Editor/Field/Field.tsx | 287 ++---------------- .../components/Editor/Field/InternalLink.tsx | 96 ++++++ .../src/app/views/ItemEdit/ItemEdit.js | 2 - 3 files changed, 120 insertions(+), 265 deletions(-) create mode 100644 src/apps/content-editor/src/app/components/Editor/Field/InternalLink.tsx diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx index 5d1bfd130..eb7647ba3 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx @@ -1,23 +1,16 @@ -import { useMemo, useCallback, useState, useEffect, ChangeEvent } from "react"; +import { useMemo, useState, useEffect, ChangeEvent } from "react"; import ReactDOM from "react-dom"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch } from "react-redux"; import moment from "moment-timezone"; import zuid from "zuid"; -import { fetchFields } from "../../../../../../../shell/store/fields"; -import { - fetchItems, - searchItems, -} from "../../../../../../../shell/store/content"; +import { searchItems } from "../../../../../../../shell/store/content"; import { EditorType, FieldShell, Error } from "./FieldShell"; import { ToggleButtonGroup, ToggleButton, Box, - Select, - MenuItem, - Chip, TextField, Dialog, IconButton, @@ -27,41 +20,29 @@ import { import CloseIcon from "@mui/icons-material/Close"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faEdit, - faExclamationTriangle, -} from "@fortawesome/free-solid-svg-icons"; +import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; // it would be nice to have a central import for all of these // instead of individually importing import { AppLink } from "@zesty-io/core/AppLink"; import { MediaApp } from "../../../../../../media/src/app"; import { FieldTypeUUID } from "../../../../../../../shell/components/FieldTypeUUID"; import { FieldTypeCurrency } from "../../../../../../../shell/components/FieldTypeCurrency"; -import { FieldTypeInternalLink } from "../../../../../../../shell/components/FieldTypeInternalLink"; -import { FieldTypeImage } from "../../../../../../../shell/components/FieldTypeImage"; import { FieldTypeEditor } from "../../../../../../../shell/components/FieldTypeEditor"; import { FieldTypeTinyMCE } from "../../../../../../../shell/components/FieldTypeTinyMCE"; import { FieldTypeColor } from "../../../../../../../shell/components/FieldTypeColor"; -import { - FieldTypeOneToMany, - OneToManyOptions, -} from "../../../../../../../shell/components/FieldTypeOneToMany"; -import { - FieldTypeOneToOne, - OneToOneOptions, -} from "../../../../../../../shell/components/FieldTypeOneToOne"; +import { OneToManyOptions } from "../../../../../../../shell/components/FieldTypeOneToMany"; import { RelationalFieldBase } from "../../../../../../../shell/components/RelationalFieldBase"; import { FieldTypeDate } from "../../../../../../../shell/components/FieldTypeDate"; import { FieldTypeDateTime } from "../../../../../../../shell/components/FieldTypeDateTime"; import { FieldTypeSort } from "../../../../../../../shell/components/FieldTypeSort"; import { FieldTypeNumber } from "../../../../../../../shell/components/FieldTypeNumber"; import { FieldTypeBlockSelector } from "../../../../../../../shell/components/FieldTypeBlockSelector"; +import { InternalLink } from "./InternalLink"; import styles from "./Field.less"; import { MemoryRouter } from "react-router"; import { withAI } from "../../../../../../../shell/components/withAi"; import { useGetContentModelFieldsQuery } from "../../../../../../../shell/services/instance"; -import { AppState } from "../../../../../../../shell/store/types"; import { ContentItem, ContentModelField, @@ -69,7 +50,6 @@ import { Language, } from "../../../../../../../shell/services/types"; import { ResolvedOption } from "./ResolvedOption"; -import { LinkOption } from "./LinkOption"; import { FieldTypeMedia } from "../../FieldTypeMedia"; const AIFieldShell = withAI(FieldShell); @@ -152,9 +132,6 @@ export const resolveRelatedOptions = ( .sort((a, b) => (a.inputLabel > b.inputLabel ? 1 : -1)); }; -const getSelectedLang = (langs: Language[], langID: number) => - langs.find((lang: any) => lang.ID === langID).code; - type FieldProps = { ZUID: string; contentModelZUID: string; @@ -192,9 +169,6 @@ export const Field = ({ minLength, }: FieldProps) => { const dispatch = useDispatch(); - const allItems = useSelector((state: AppState) => state.content); - const allFields = useSelector((state: AppState) => state.fields); - const allLanguages = useSelector((state: AppState) => state.languages); const { data: fields } = useGetContentModelFieldsQuery(contentModelZUID); const [imageModal, setImageModal] = useState(null); @@ -648,255 +622,42 @@ export const Field = ({ ); case "internal_link": - let internalLinkRelatedItem = allItems[value]; - let internalLinkOptions = useMemo(() => { - const options = Object.keys(allItems) - .filter( - (itemZUID) => - !itemZUID.includes("new") && // exclude new items - allItems[itemZUID].meta.ZUID && // ensure the item has a zuid - allItems[itemZUID].web.pathPart && // exclude non-routeable items - allItems[itemZUID].meta.langID === langID // exclude non-relevant langs - ) - .map((itemZUID) => { - let item = allItems[itemZUID]; - let html = ""; - - if (item.web.metaTitle) { - html += `${item.web.metaTitle}`; - } else { - return { - component: ( - - ), - }; - } - - if (item.web.path || item.web.pathPart) { - html += `${ - item.web.path || item.web.pathPart - }`; - } - - return { - value: itemZUID, - html: html, - }; - }) - .sort(sortHTML); - - // if the selected item is not found, insert a placeholder - if (internalLinkRelatedItem && !internalLinkRelatedItem?.meta?.ZUID) { - // insert placeholder - options.unshift({ - value: value as string, - html: `Selected item not found: ${value}`, - }); - } - - return options; - }, [internalLinkRelatedItem, Object.keys(allItems).length]); - - const onInternalLinkSearch = useCallback( - (term) => dispatch(searchItems(term)), - [] - ); - return ( - !!error)} + langID={langID} /> ); case "one_to_one": - const onOneToOneOpen = useCallback(() => { - if (zuid.isValid(relatedModelZUID)) { - return dispatch( - fetchItems(relatedModelZUID, { - lang: getSelectedLang(allLanguages, langID), - }) - ); - } else { - return Promise.reject(new Error("Missing modelZUID")); - } - }, [allLanguages.length, relatedModelZUID, langID]); - - let oneToOneOptions: OneToManyOptions[] = useMemo(() => { - const options = filterValidItems(allItems); - - return [ - { - inputLabel: "- None -", - value: null, - component: "- None -", - }, - ...resolveRelatedOptions( - allFields, - options, - relatedFieldZUID, - relatedModelZUID, - langID, - value - ), - ]; - }, [ - Object.keys(allFields).length, - Object.keys(allItems).length, - relatedModelZUID, - relatedFieldZUID, - langID, - value, - ]); - - if (value && !oneToOneOptions.find((opt) => opt.value === value)) { - //the related option is not in the array, we need to insert it - oneToOneOptions.unshift({ - value: value as string, - inputLabel: `Selected item not found: ${value}`, - component: ( - - evt.stopPropagation()}> - - - - -  Selected item not found: {value} - - ), - }); - } - return ( - <> - - {/** - options.value === value) || - null - } - onChange={(_, option) => onChange(option.value, name)} - options={oneToOneOptions} - onOpen={onOneToOneOpen} - startAdornment={ - value && ( - - - - ) - } - endAdornment={ - value && {getSelectedLang(allLanguages, langID)} - } - error={errors && Object.values(errors)?.some((error) => !!error)} - /> - */} - + ); case "one_to_many": - const oneToManyOptions: OneToManyOptions[] = useMemo(() => { - const options = filterValidItems(allItems); - - return resolveRelatedOptions( - allFields, - options, - relatedFieldZUID, - relatedModelZUID, - langID, - value - ); - }, [ - Object.keys(allFields).length, - Object.keys(allItems).length, - relatedModelZUID, - relatedFieldZUID, - langID, - value, - ]); - - // Delay loading options until user opens dropdown - const onOneToManyOpen = useCallback(() => { - return Promise.all([ - dispatch(fetchFields(relatedModelZUID)), - dispatch( - fetchItems(relatedModelZUID, { - lang: getSelectedLang(allLanguages, langID), - }) - ), - ]); - }, [allLanguages.length, relatedModelZUID, langID]); - return ( - <> - - {/** - - oneToManyOptions?.find( - (options) => options.value === value - ) || { value, inputLabel: value, component: value } - )) || - [] - } - onChange={(_, options: OneToManyOptions[]) => { - const selectedOptions = options?.length - ? options.map((option) => option.value).join(",") - : null; - onChange(selectedOptions, name); - }} - options={oneToManyOptions} - onOpen={onOneToManyOpen} - renderTags={(tags, getTagProps) => - tags.map((tag, index) => ( - - )) - } - error={errors && Object.values(errors)?.some((error) => !!error)} - /> - */} - + ); diff --git a/src/apps/content-editor/src/app/components/Editor/Field/InternalLink.tsx b/src/apps/content-editor/src/app/components/Editor/Field/InternalLink.tsx new file mode 100644 index 000000000..580ef2f1a --- /dev/null +++ b/src/apps/content-editor/src/app/components/Editor/Field/InternalLink.tsx @@ -0,0 +1,96 @@ +import { useDispatch, useSelector } from "react-redux"; +import { FieldTypeInternalLink } from "../../../../../../../shell/components/FieldTypeInternalLink"; +import { AppState } from "../../../../../../../shell/store/types"; +import { useCallback, useMemo } from "react"; +import { searchItems } from "../../../../../../../shell/store/content"; +import { LinkOption } from "./LinkOption"; +import { sortHTML } from "./Field"; + +type InternalLinkProps = { + name: string; + value: string; + onChange: (value: any, name: string, datatype?: string) => void; + // onInternalLinkSearch: (search: string) => void; + // internalLinkOptions: any; + error: boolean; + langID: number; +}; +export const InternalLink = ({ + name, + value, + onChange, + error, + langID, +}: InternalLinkProps) => { + const dispatch = useDispatch(); + + const allItems = useSelector((state: AppState) => state.content); + let internalLinkRelatedItem = allItems[value]; + let internalLinkOptions = useMemo(() => { + const options = Object.keys(allItems) + .filter( + (itemZUID) => + !itemZUID.includes("new") && // exclude new items + allItems[itemZUID].meta.ZUID && // ensure the item has a zuid + allItems[itemZUID].web.pathPart && // exclude non-routeable items + allItems[itemZUID].meta.langID === langID // exclude non-relevant langs + ) + .map((itemZUID) => { + let item = allItems[itemZUID]; + let html = ""; + + if (item.web.metaTitle) { + html += `${item.web.metaTitle}`; + } else { + return { + component: ( + + ), + }; + } + + if (item.web.path || item.web.pathPart) { + html += `${ + item.web.path || item.web.pathPart + }`; + } + + return { + value: itemZUID, + html: html, + }; + }) + .sort(sortHTML); + + // if the selected item is not found, insert a placeholder + if (internalLinkRelatedItem && !internalLinkRelatedItem?.meta?.ZUID) { + // insert placeholder + options.unshift({ + value: value as string, + html: `Selected item not found: ${value}`, + }); + } + + return options; + }, [internalLinkRelatedItem, Object.keys(allItems).length]); + + const onInternalLinkSearch = useCallback( + (term) => dispatch(searchItems(term)), + [] + ); + + return ( + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js index 3be92ddd2..d67cd6a98 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js @@ -85,7 +85,6 @@ export default function ItemEdit() { const metaRef = useRef(null); const fieldErrorRef = useRef(null); const item = useSelector((state) => state.content[itemZUID]); - const items = useSelector((state) => state.content); const model = useSelector((state) => state.models[modelZUID]); const tags = useSelector((state) => selectItemHeadTags(state, itemZUID)); const languages = useSelector((state) => state.languages); @@ -603,7 +602,6 @@ export default function ItemEdit() { fields={fields} itemZUID={itemZUID} item={item} - items={items} user={user} onSave={() => save().catch((err) => console.error(err)) From 0f3e5f5240e5c1e0865998fe2acb2d23115eb9df Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 6 Feb 2025 13:08:07 +0800 Subject: [PATCH 58/66] Filter out draft items --- .../FieldSelectorDialog/index.tsx | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index 1678851b9..74f473716 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -214,43 +214,45 @@ export const FieldSelectorDialog = ({ let _rows = [...contentItems]; - return _rows?.map((item) => ({ - id: item.meta?.ZUID, - image: { - imageFieldName, - itemZUID: item.meta?.ZUID, - }, - title: { - primary: - item.data?.[relatedFieldName] || - item.web?.metaTitle || - item.web?.metaLinkText, - secondary: item.web?.metaDescription, - }, - version: { - itemData: { - ...item, - createdByName: resolveUserZUID(item.meta?.createdByUserZUID), + return _rows + ?.filter((item) => !item.meta?.ZUID?.startsWith("new")) + ?.map((item) => ({ + id: item.meta?.ZUID, + image: { + imageFieldName, + itemZUID: item.meta?.ZUID, }, - publishData: item?.publishing?.version - ? { - ...item.publishing, - publishedByName: resolveUserZUID( - item.publishing?.publishedByUserZUID - ), - } - : null, - scheduleData: item?.scheduling?.version - ? { - ...item.scheduling, - scheduledByName: resolveUserZUID( - item.scheduling?.publishedByUserZUID - ), - } - : null, - }, - item, - })); + title: { + primary: + item.data?.[relatedFieldName] || + item.web?.metaTitle || + item.web?.metaLinkText, + secondary: item.web?.metaDescription, + }, + version: { + itemData: { + ...item, + createdByName: resolveUserZUID(item.meta?.createdByUserZUID), + }, + publishData: item?.publishing?.version + ? { + ...item.publishing, + publishedByName: resolveUserZUID( + item.publishing?.publishedByUserZUID + ), + } + : null, + scheduleData: item?.scheduling?.version + ? { + ...item.scheduling, + scheduledByName: resolveUserZUID( + item.scheduling?.publishedByUserZUID + ), + } + : null, + }, + item, + })); }, [contentItems, users, relatedFieldName, imageFieldName]); const rows = useMemo(() => { From 047b0fcbe1d9561bd6c95875ab7a9821ceeb4ccd Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 6 Feb 2025 13:27:57 +0800 Subject: [PATCH 59/66] Dispatch event when new item is successfully created via dialog --- .../src/app/views/ItemCreate/ItemCreate.tsx | 43 +++++++++++++++---- .../CreateNewItemDialog.tsx | 2 +- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx index ff5819d98..41f11c78d 100644 --- a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx +++ b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx @@ -38,6 +38,8 @@ import { Meta } from "../ItemEdit/Meta"; import { SocialMediaPreview } from "../ItemEdit/Meta/SocialMediaPreview"; import { FieldError } from "../../components/Editor/FieldError"; import { AIGeneratorProvider } from "../../../../../../shell/components/withAi/AIGeneratorProvider"; +import { useParams as useQueryParams } from "../../../../../../shell/hooks/useParams"; +import { CREATE_NEW_ITEM_DIALOG_EVENTS } from "../../../../../../shell/components/RelationalFieldBase/CreateNewItemDialog"; export type ActionAfterSave = | "" @@ -65,6 +67,8 @@ export const ItemCreate = () => { const history = useHistory(); const isMounted = useIsMounted(); const dispatch = useDispatch(); + const [queryParams] = useQueryParams(); + const isRenderedAsDialog = queryParams.get("isDialog") === "true"; const { modelZUID } = useParams<{ modelZUID: string }>(); const itemZUID = `new:${modelZUID}`; const model = useSelector((state: AppState) => state.models[modelZUID]); @@ -79,7 +83,7 @@ export const ItemCreate = () => { const [active, setActive] = useState(); const [newItemZUID, setNewItemZUID] = useState(); const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false); - const [willRedirect, setWillRedirect] = useState(true); + const [willRedirect, setWillRedirect] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); const [saveClicked, setSaveClicked] = useState(false); // const [hasSEOErrors, setHasSEOErrors] = useState(false); @@ -125,7 +129,7 @@ export const ItemCreate = () => { if (!isPublishing && isPublished) { // console.log("will it redirect?", redirect); if (willRedirect) { - history.push(`/content/${modelZUID}/${publishedItem?.data?.itemZUID}`); + handleRedirect(publishedItem?.data?.itemZUID); } } }, [isPublishing, isPublished, publishedItem, willRedirect]); @@ -303,6 +307,7 @@ export const ItemCreate = () => { await dispatch(fetchItem(modelZUID, res.data.ZUID)); setNewItemZUID(res.data.ZUID); + setFieldErrors({}); switch (action) { case "addNew": @@ -336,11 +341,7 @@ export const ItemCreate = () => { default: // Redirect to new item - history.push( - `/${ - model?.type === "block" ? "blocks" : "content" - }/${modelZUID}/${res.data.ZUID}` - ); + handleRedirect(res.data.ZUID); break; } @@ -384,6 +385,24 @@ export const ItemCreate = () => { }); }; + const handleRedirect = (itemZUID: string) => { + if (isRenderedAsDialog) { + window.dispatchEvent( + new CustomEvent(CREATE_NEW_ITEM_DIALOG_EVENTS.ITEM_CREATED, { + detail: { + itemZUID, + }, + }) + ); + } else { + history.push( + `/${ + model?.type === "block" ? "blocks" : "content" + }/${modelZUID}/${itemZUID}` + ); + } + }; + if (!loading && !model) { return ; } @@ -512,13 +531,19 @@ export const ItemCreate = () => { {isScheduleDialogOpen && !isLoadingNewItem && ( setIsScheduleDialogOpen(false)} + onClose={() => { + setIsScheduleDialogOpen(false); + + if (willRedirect) { + handleRedirect(newItemData?.meta?.ZUID); + } + }} onPublishNow={() => handlePublish(newItemZUID)} onScheduleSuccess={() => { setIsScheduleDialogOpen(false); if (willRedirect) { - history.push(`/content/${modelZUID}/${newItemData?.meta?.ZUID}`); + handleRedirect(newItemData?.meta?.ZUID); } }} /> diff --git a/src/shell/components/RelationalFieldBase/CreateNewItemDialog.tsx b/src/shell/components/RelationalFieldBase/CreateNewItemDialog.tsx index a29b671de..0e9f65a76 100644 --- a/src/shell/components/RelationalFieldBase/CreateNewItemDialog.tsx +++ b/src/shell/components/RelationalFieldBase/CreateNewItemDialog.tsx @@ -11,7 +11,7 @@ export const CREATE_NEW_ITEM_DIALOG_EVENTS = { } as const; type CreateNewItemDialogProps = { modelZUID: string; - onItemCreated: () => void; + onItemCreated: (evt: CustomEvent) => void; onClose: () => void; }; export const CreateNewItemDialog = ({ From a18b66ae331cc2a29dd0e3e2ce57ad17dea39bc9 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 6 Feb 2025 13:28:11 +0800 Subject: [PATCH 60/66] Add newly-created item from dialog to the selected zuid list --- src/shell/components/RelationalFieldBase/index.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index 20215b712..f99833e76 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -180,7 +180,18 @@ export const RelationalFieldBase = ({ {isCreateNewItemDialogOpen && ( {}} + onItemCreated={(evt) => { + setIsCreateNewItemDialogOpen(false); + + const { itemZUID } = evt.detail; + const newItemZUIDs = [...itemZUIDs, itemZUID]; + + onChange( + !!newItemZUIDs?.length ? newItemZUIDs.join(",") : null, + name + ); + setItemZUIDs(!!newItemZUIDs?.length ? newItemZUIDs : null); + }} onClose={() => setIsCreateNewItemDialogOpen(false)} /> )} From 88710a07ed05553d479f17f2678b4460c2660309 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Thu, 6 Feb 2025 13:34:13 +0800 Subject: [PATCH 61/66] Hide Create & Add New button when field is rendered in the create & add new modal --- src/shell/components/RelationalFieldBase/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index f99833e76..6ccf97b40 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -19,6 +19,7 @@ import { import { fetchItems } from "../../store/content"; import { ActiveItemLoading } from "./ActiveItem/ActiveItemLoading"; import { CreateNewItemDialog } from "./CreateNewItemDialog"; +import { useParams } from "../../hooks/useParams"; type RelationalFieldBaseProps = { name: string; @@ -37,6 +38,7 @@ export const RelationalFieldBase = ({ multiselect, }: RelationalFieldBaseProps) => { const dispatch = useDispatch(); + const [params] = useParams(); const [itemZUIDs, setItemZUIDs] = useState(value?.split(",") || []); const [showAll, setShowAll] = useState(false); const [anchorEl, setAnchorEl] = useState(null); @@ -75,6 +77,8 @@ export const RelationalFieldBase = ({ onChange(itemZUIDs?.join(","), name); }, [itemZUIDs]); + const isRenderedAsDialog = params.get("isDialog") === "true"; + return ( @@ -142,7 +146,7 @@ export const RelationalFieldBase = ({ > Add Existing {modelData?.label} - {multiselect && ( + {multiselect && !isRenderedAsDialog && ( @@ -154,7 +154,9 @@ export const RelationalFieldBase = ({ startIcon={} fullWidth onClick={() => setIsCreateNewItemDialogOpen(true)} - disabled={isLoadingModelData || isLoadingModelFields} + disabled={ + isLoadingModelData || isLoadingModelFields || !modelData + } > Create & Add New {modelData?.label} From 6a1f39c6f0682f38540b97510a3b2b389212ac1b Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Tue, 11 Feb 2025 09:58:12 +0800 Subject: [PATCH 63/66] Add test for creating and adding a new item in one-to-many field --- cypress/e2e/content/content.spec.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/content/content.spec.js b/cypress/e2e/content/content.spec.js index 84a31e224..7b9bddc87 100644 --- a/cypress/e2e/content/content.spec.js +++ b/cypress/e2e/content/content.spec.js @@ -494,7 +494,7 @@ describe("Content Specs", () => { }); }); - context("One to many field", () => { + context.only("One to many field", () => { before(() => { cy.waitOn("/v1/content/models*", () => { cy.visit("/content/6-556370-8sh47g/7-b939a4-457q19"); @@ -536,5 +536,26 @@ describe("Content Specs", () => { 2 ); }); + + it("can create & add new item", () => { + cy.get( + "#12-269a28-1bkm34 [data-cy='create-new-relational-item-button']" + ).click({ + force: true, + }); + + cy.get("#12-d6e4c1d797-sjv628", { retries: 1 }) + .find("input") + .type(`Test Item ${TIMESTAMP}`); + cy.get("#12-aaa5ce87e3-89whjq") + .find("textarea") + .first() + .type(`Test Item ${TIMESTAMP}`); + cy.getBySelector("CreateItemSaveButton").click(); + + cy.get("#12-269a28-1bkm34 [data-cy='active-relational-item']", { + retries: 1, + }).should("have.length", 3); + }); }); }); From 5253badfd1909a2697c5975b2982d59dc75eb16d Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 12 Feb 2025 14:41:06 +0800 Subject: [PATCH 64/66] Refactor one-to-many field context and remove unused props from InternalLink component --- cypress/e2e/content/content.spec.js | 2 +- .../src/app/components/Editor/Field/InternalLink.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/cypress/e2e/content/content.spec.js b/cypress/e2e/content/content.spec.js index 7b9bddc87..bbcb00f72 100644 --- a/cypress/e2e/content/content.spec.js +++ b/cypress/e2e/content/content.spec.js @@ -494,7 +494,7 @@ describe("Content Specs", () => { }); }); - context.only("One to many field", () => { + context("One to many field", () => { before(() => { cy.waitOn("/v1/content/models*", () => { cy.visit("/content/6-556370-8sh47g/7-b939a4-457q19"); diff --git a/src/apps/content-editor/src/app/components/Editor/Field/InternalLink.tsx b/src/apps/content-editor/src/app/components/Editor/Field/InternalLink.tsx index 580ef2f1a..9674ff521 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/InternalLink.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/InternalLink.tsx @@ -10,8 +10,6 @@ type InternalLinkProps = { name: string; value: string; onChange: (value: any, name: string, datatype?: string) => void; - // onInternalLinkSearch: (search: string) => void; - // internalLinkOptions: any; error: boolean; langID: number; }; From c88f2726a5017434fad166b820d05124eddb0a9f Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 19 Feb 2025 11:43:07 +0800 Subject: [PATCH 65/66] [Content] Add CreateContentItemDialogProvider and integrate into ItemCreate flow --- .../src/app/components/Editor/Field/Field.tsx | 2 + .../src/app/views/ItemCreate/Header.tsx | 9 ++-- .../src/app/views/ItemCreate/ItemCreate.tsx | 19 ++++----- .../AddFieldModal/DefaultValueInput.tsx | 2 + .../CreateNewItemDialog.tsx | 21 +--------- .../components/RelationalFieldBase/index.tsx | 42 +++++++++++-------- .../CreateContentItemDialogProvider.tsx | 39 +++++++++++++++++ src/shell/index.js | 13 +++--- 8 files changed, 89 insertions(+), 58 deletions(-) create mode 100644 src/shell/contexts/CreateContentItemDialogProvider.tsx diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx index 6ae6f3c02..867557f43 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx @@ -657,6 +657,7 @@ export const Field = ({ > = { @@ -51,6 +51,7 @@ export const Header = ({ model, onSave, isLoading, isDirty }: Props) => { const [dropdownMenuType, setDropdownMenuType] = useState(null); const [anchorEl, setAnchorEl] = useState(null); + const [_, setInitiatorZUID] = useContext(CreateContentItemDialogContext); const metaShortcut = useMetaKey("s", onSave); const isRenderedAsDialog = params.get("isDialog") === "true"; @@ -171,9 +172,7 @@ export const Header = ({ model, onSave, isLoading, isDirty }: Props) => { { - window.dispatchEvent( - new CustomEvent(CREATE_NEW_ITEM_DIALOG_EVENTS.CLOSE) - ); + setInitiatorZUID(null); }} > diff --git a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx index 41f11c78d..a071b7a3c 100644 --- a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx +++ b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useRef } from "react"; +import { useEffect, useMemo, useState, useRef, useContext } from "react"; import { useDispatch, useSelector } from "react-redux"; import useIsMounted from "ismounted"; import { useHistory, useParams } from "react-router-dom"; @@ -39,7 +39,7 @@ import { SocialMediaPreview } from "../ItemEdit/Meta/SocialMediaPreview"; import { FieldError } from "../../components/Editor/FieldError"; import { AIGeneratorProvider } from "../../../../../../shell/components/withAi/AIGeneratorProvider"; import { useParams as useQueryParams } from "../../../../../../shell/hooks/useParams"; -import { CREATE_NEW_ITEM_DIALOG_EVENTS } from "../../../../../../shell/components/RelationalFieldBase/CreateNewItemDialog"; +import { CreateContentItemDialogContext } from "../../../../../../shell/contexts/CreateContentItemDialogProvider"; export type ActionAfterSave = | "" @@ -67,6 +67,9 @@ export const ItemCreate = () => { const history = useHistory(); const isMounted = useIsMounted(); const dispatch = useDispatch(); + const [_, __, ___, setNewlyCreatedItemZUID] = useContext( + CreateContentItemDialogContext + ); const [queryParams] = useQueryParams(); const isRenderedAsDialog = queryParams.get("isDialog") === "true"; const { modelZUID } = useParams<{ modelZUID: string }>(); @@ -387,13 +390,7 @@ export const ItemCreate = () => { const handleRedirect = (itemZUID: string) => { if (isRenderedAsDialog) { - window.dispatchEvent( - new CustomEvent(CREATE_NEW_ITEM_DIALOG_EVENTS.ITEM_CREATED, { - detail: { - itemZUID, - }, - }) - ); + setNewlyCreatedItemZUID(itemZUID); } else { history.push( `/${ @@ -441,7 +438,7 @@ export const ItemCreate = () => { )} - {model.type === "block" && ( + {model?.type === "block" && ( { setSEOErrors(errors); @@ -471,7 +468,7 @@ export const ItemCreate = () => { setFieldErrors(errors); }} /> - {model.type !== "block" && ( + {model?.type !== "block" && ( { setSEOErrors(errors); diff --git a/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx index fea9fa710..310e142d4 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx @@ -306,6 +306,7 @@ export const DefaultValueInput = ({ return ( void; onClose: () => void; }; export const CreateNewItemDialog = ({ modelZUID, - onItemCreated, onClose, }: CreateNewItemDialogProps) => { - useEffect(() => { - window.addEventListener(CREATE_NEW_ITEM_DIALOG_EVENTS.CLOSE, onClose); - window.addEventListener( - CREATE_NEW_ITEM_DIALOG_EVENTS.ITEM_CREATED, - onItemCreated - ); - - return () => { - window.removeEventListener(CREATE_NEW_ITEM_DIALOG_EVENTS.CLOSE, onClose); - window.addEventListener( - CREATE_NEW_ITEM_DIALOG_EVENTS.ITEM_CREATED, - onItemCreated - ); - }; - }, []); - return createPortal( void; @@ -34,6 +36,7 @@ export const RelationalFieldBase = ({ name, value, fieldLabel, + fieldZUID, relatedModelZUID, relatedFieldZUID, onChange, @@ -44,8 +47,12 @@ export const RelationalFieldBase = ({ const [itemZUIDs, setItemZUIDs] = useState(value?.split(",") || []); const [showAll, setShowAll] = useState(false); const [anchorEl, setAnchorEl] = useState(null); - const [isCreateNewItemDialogOpen, setIsCreateNewItemDialogOpen] = - useState(false); + const [ + initiatorZUID, + setInitiatorZUID, + newlyCreatedItemZUID, + setNewlyCreatedItemZUID, + ] = useContext(CreateContentItemDialogContext); const { data: modelData, isLoading: isLoadingModelData } = useGetContentModelQuery(relatedModelZUID, { @@ -62,6 +69,17 @@ export const RelationalFieldBase = ({ } }, [relatedModelZUID]); + useEffect(() => { + if (!!newlyCreatedItemZUID && initiatorZUID === fieldZUID) { + const newItemZUIDs = [...itemZUIDs, newlyCreatedItemZUID]; + + onChange(!!newItemZUIDs?.length ? newItemZUIDs.join(",") : null, name); + setItemZUIDs(!!newItemZUIDs?.length ? newItemZUIDs : null); + setInitiatorZUID(null); + setNewlyCreatedItemZUID(null); + } + }, [newlyCreatedItemZUID, itemZUIDs, initiatorZUID]); + const handleMoveCard = useCallback( (draggedItemZUID: string, dropIndex: number) => { const draggedIndex = itemZUIDs.indexOf(draggedItemZUID); @@ -160,7 +178,7 @@ export const RelationalFieldBase = ({ size="large" startIcon={} fullWidth - onClick={() => setIsCreateNewItemDialogOpen(true)} + onClick={() => setInitiatorZUID(fieldZUID)} disabled={isLoading || !modelData} > Create & Add New {modelData?.label} @@ -188,22 +206,10 @@ export const RelationalFieldBase = ({ }} /> )} - {isCreateNewItemDialogOpen && ( + {initiatorZUID === fieldZUID && ( { - setIsCreateNewItemDialogOpen(false); - - const { itemZUID } = evt.detail; - const newItemZUIDs = [...itemZUIDs, itemZUID]; - - onChange( - !!newItemZUIDs?.length ? newItemZUIDs.join(",") : null, - name - ); - setItemZUIDs(!!newItemZUIDs?.length ? newItemZUIDs : null); - }} - onClose={() => setIsCreateNewItemDialogOpen(false)} + onClose={() => setInitiatorZUID(null)} /> )} diff --git a/src/shell/contexts/CreateContentItemDialogProvider.tsx b/src/shell/contexts/CreateContentItemDialogProvider.tsx new file mode 100644 index 000000000..8b5e980b7 --- /dev/null +++ b/src/shell/contexts/CreateContentItemDialogProvider.tsx @@ -0,0 +1,39 @@ +import React, { createContext, useState, Dispatch } from "react"; + +type CreateContentItemDialogContextType = [ + string, + Dispatch, + string, + Dispatch +]; +export const CreateContentItemDialogContext = + createContext([ + null, + () => {}, + null, + () => {}, + ]); + +type CreateContentItemDialogProviderType = { + children?: React.ReactNode; +}; +export const CreateContentItemDialogProvider = ({ + children, +}: CreateContentItemDialogProviderType) => { + const [initiatorZUID, setInitiatorZUID] = useState(null); + const [newlyCreatedItemZUID, setNewlyCreatedItemZUID] = + useState(null); + + return ( + + {children} + + ); +}; diff --git a/src/shell/index.js b/src/shell/index.js index 9c4422b8d..37eec9f30 100644 --- a/src/shell/index.js +++ b/src/shell/index.js @@ -34,6 +34,7 @@ import Shell from "./views/Shell"; import { MonacoSetup } from "../apps/code-editor/src/app/components/Editor/components/MemoizedEditor/MonacoSetup"; import { actions } from "shell/store/ui"; import { CommentProvider } from "./contexts/CommentProvider"; +import { CreateContentItemDialogProvider } from "./contexts/CreateContentItemDialogProvider"; // needed for Breadcrumbs in Shell injectReducer(store, "navContent", navContent); @@ -60,11 +61,13 @@ const App = Sentry.withProfiler(() => ( - - - - - + + + + + + + From 335e2a1cde6ce9c34ba2944301a960568261b964 Mon Sep 17 00:00:00 2001 From: Nar Cuenca Date: Wed, 19 Feb 2025 11:46:33 +0800 Subject: [PATCH 66/66] Refactor RelationalFieldBase to use fieldLabel for button text --- src/shell/components/RelationalFieldBase/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shell/components/RelationalFieldBase/index.tsx b/src/shell/components/RelationalFieldBase/index.tsx index 248c6a1b4..1ebd80429 100644 --- a/src/shell/components/RelationalFieldBase/index.tsx +++ b/src/shell/components/RelationalFieldBase/index.tsx @@ -169,7 +169,7 @@ export const RelationalFieldBase = ({ onClick={(evt) => setAnchorEl(evt.currentTarget)} disabled={isLoading || !modelData} > - Add Existing {modelData?.label} + Add Existing {fieldLabel} {multiselect && !isRenderedAsDialog && ( )}