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 02059a80b..6f9c30438 100644 --- a/src/apps/content-editor/src/app/components/Editor/Editor.js +++ b/src/apps/content-editor/src/app/components/Editor/Editor.js @@ -47,6 +47,8 @@ export default memo(function Editor({ const isNewItem = itemZUID.slice(0, 3) === "new"; const { data: fields } = useGetContentModelFieldsQuery(modelZUID); const [isLoaded, setIsLoaded] = useState(false); + const [prevFirstContentFieldValue, setPrevFirstContentFieldValue] = + useState(null); const metaFields = useMemo(() => { if (fields?.length) { @@ -309,12 +311,20 @@ export default memo(function Editor({ ?.slice(0, 160) || "" ); - dispatch({ - type: "SET_ITEM_WEB", - itemZUID, - key: "metaDescription", - value: cleanedValue, - }); + if ( + item?.web?.["metaDescription"] === prevFirstContentFieldValue || + !item?.web?.["metaDescription"] || + !prevFirstContentFieldValue + ) { + dispatch({ + type: "SET_ITEM_WEB", + itemZUID, + key: "metaDescription", + value: cleanedValue, + }); + + setPrevFirstContentFieldValue(cleanedValue); + } if ("og_description" in metaFields) { dispatch({ @@ -336,7 +346,13 @@ export default memo(function Editor({ } } }, - [fieldErrors, metaFields] + [ + fieldErrors, + metaFields, + item, + prevFirstContentFieldValue, + setPrevFirstContentFieldValue, + ] ); const applyDefaultValuesToItemData = useCallback(() => { diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index 377fa7dc3..d70afaeb9 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -331,9 +331,8 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { })), ]; } - return result; + return [...result, ...METADATA_COLUMNS]; }, [fields]); - if (!initialState) { return ( { apiRef={apiRef} loading={loading} rows={rows} - columns={[...columns, ...METADATA_COLUMNS]} + columns={columns} pinnedColumns={pinnedColumns} onPinnedColumnsChange={(newPinnedColumns) => setPinnedColumns(newPinnedColumns) @@ -425,10 +424,6 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { !(stagedChanges && Object.keys(stagedChanges)?.length) } sx={{ - ...(!rows?.length && - !loading && { - maxHeight: 56, - }), backgroundColor: "common.white", ".MuiDataGrid-row": { cursor: "pointer", diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx index 70f1a4ef2..fe76c72bb 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from "react"; import { Box, Chip, Tooltip } from "@mui/material"; -import { useDispatch, useSelector } from "react-redux"; +import { shallowEqual, useSelector } from "react-redux"; import { AppState } from "../../../../../../../shell/store/types"; import { searchItems } from "../../../../../../../shell/store/content"; +import { createSelector } from "reselect"; const getNumOfItemsToRender = ( parentWidth: number, @@ -25,15 +26,20 @@ const getNumOfItemsToRender = ( return validIndex; }; +const selectContent = (state: AppState) => state.content; + +const selectContentCount = createSelector( + [selectContent], + (content) => Object.keys(content).length +); + type OneToManyCellProps = { items: any[]; }; export const OneToManyCell = ({ items }: OneToManyCellProps) => { - const allItems = useSelector((state: AppState) => state.content); + const contentCount = useSelector(selectContentCount, shallowEqual); const chipContainerRef = useRef(); - const [lastValidIndex, setLastValidIndex] = useState( - Object.keys(allItems)?.length - 1 - ); + const [lastValidIndex, setLastValidIndex] = useState(contentCount - 1); const parentWidth = chipContainerRef.current?.parentElement?.clientWidth; const hiddenItems = items?.length - lastValidIndex - 1; diff --git a/src/apps/content-editor/src/app/views/ItemList/index.tsx b/src/apps/content-editor/src/app/views/ItemList/index.tsx index 57cbc7724..9623c49c2 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -21,10 +21,6 @@ import { SearchRounded, RestartAltRounded } from "@mui/icons-material"; import noSearchResults from "../../../../../../../public/images/noSearchResults.svg"; import { ItemListFilters } from "./ItemListFilters"; import { useParams } from "../../../../../../shell/hooks/useParams"; -import { - useGetAllBinFilesQuery, - useGetBinsQuery, -} from "../../../../../../shell/services/mediaManager"; import { useDispatch, useSelector } from "react-redux"; import { AppState } from "../../../../../../shell/store/types"; import { useStagedChanges } from "./StagedChangesContext"; @@ -41,6 +37,7 @@ import { import { fetchItems } from "../../../../../../shell/store/content"; import { TableSortContext } from "./TableSortProvider"; import { fetchFields } from "../../../../../../shell/store/fields"; +import { debounce } from "lodash"; const formatDateTime = (source: string) => { const dateObj = new Date(source); @@ -70,22 +67,6 @@ const formatDate = (source: string) => { }); }; -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 const ItemList = () => { const { modelZUID } = useRouterParams<{ modelZUID: string }>(); const [params, setParams] = useParams(); @@ -98,17 +79,20 @@ export const ItemList = () => { const { data: languages } = useGetLangsQuery({}); const activeLangId = languages?.find((lang) => lang.code === activeLanguageCode)?.ID || 1; - const [hasMounted, setHasMounted] = useState(false); const allItems = useSelector((state: AppState) => state.content); - const items = useSelector((state: AppState) => - selectFilteredItems(state, modelZUID, activeLangId, !hasMounted) - ); + const items = useMemo(() => { + return Object.values(allItems).filter( + (item: ContentItem) => + item.meta.contentModelZUID === modelZUID && + item.meta.langID === activeLangId + ); + }, [allItems, modelZUID, activeLangId]); + const allFields = useSelector((state: AppState) => state.fields); const user = useSelector((state: AppState) => state.user); const { data: users, isFetching: isUsersFetching } = useGetUsersQuery(); - + const [processedItems, setProcessedItems] = useState([]); const [isModelItemsFetching, setIsModelItemsFetching] = useState(true); - const [sortModel] = useContext(TableSortContext); const { stagedChanges } = useStagedChanges(); const [selectedItems] = useSelectedItems(); @@ -171,9 +155,6 @@ export const ItemList = () => { useEffect(() => { dispatch(fetchFields(modelZUID)); - setTimeout(() => { - setHasMounted(true); - }, 0); }, []); useEffect(() => { @@ -199,9 +180,8 @@ export const ItemList = () => { } }, [languages, activeLanguageCode]); - const processedItems = useMemo(() => { + const computeProcessedItems = useCallback(() => { if (!items || isFieldsFetching || isUsersFetching) return []; - const fieldMap = fields?.reduce((acc, field) => { // @ts-ignore acc[field.name] = field.datatype; @@ -304,6 +284,29 @@ export const ItemList = () => { }); }, [items, allItems, fields, users, isFieldsFetching, isUsersFetching]); + /* + We debounce the processed items compute since certain fields can call for items to be fetched if they are not in memory + and we don't want to trigger a heavy computation on every item fetched in parallel. This reduces initial load time. + */ + const debouncedCompute = useMemo(() => { + return debounce(() => { + setProcessedItems(computeProcessedItems()); + }, 300); + }, [computeProcessedItems]); + + useEffect(() => { + debouncedCompute(); + return () => debouncedCompute.cancel(); + }, [ + debouncedCompute, + items, + allItems, + fields, + users, + isFieldsFetching, + isUsersFetching, + ]); + const sortedAndFilteredItems = useMemo(() => { const sort = sortModel?.[0]?.field; const sortOrder = sortModel?.[0]?.sort; @@ -634,6 +637,7 @@ export const ItemList = () => { flex={1} display="flex" alignItems="center" + paddingBottom={12} > { {!sortedAndFilteredItems?.length && !isModelItemsFetching && !search && - (!!sortModel?.length || - statusFilter || + (statusFilter || dateFilter?.preset || dateFilter?.from || dateFilter?.to || @@ -676,6 +679,7 @@ export const ItemList = () => { flex={1} display="flex" alignItems="center" + paddingBottom={12} > = React.memo( // Makes sure that the add new content icon color does not change when tree item is selected color: "common.white", }, + //Override the nested menu item text color set by .Mui-selected. + ".MuiMenu-root .MuiList-root .MuiListItemText-root .MuiTypography-root": + { + color: "common.black", + }, }, "& .MuiCollapse-root.MuiTreeItem-group": { // This makes sure that the whole row is highlighted while still maintaining tree item depth diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/dateFilter.ts b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/dateFilter.ts new file mode 100644 index 000000000..8d1d914ca --- /dev/null +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/dateFilter.ts @@ -0,0 +1,23 @@ +import { GridFilterOperator } from "@mui/x-data-grid-pro"; +import { getDateFilterFnByValues } from "../../Filters/DateFilter/getDateFilter"; + +export const dateFilterOperator: GridFilterOperator = { + label: "dateFilter", + value: "dateFilter", + getApplyFilterFn: (filterItem) => { + if (!filterItem.value) { + return null; + } + + return (params): boolean => { + const version = params.value; + const dateFilterFn = getDateFilterFnByValues(filterItem.value); + + if (!dateFilterFn || !version?.itemData?.meta?.updatedAt) { + return false; + } + + return dateFilterFn(version.itemData.meta.updatedAt); + }; + }, +}; diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx index 4515d2b91..c5358e7f0 100644 --- a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/index.tsx @@ -19,6 +19,7 @@ import { GridColumns, GridInputSelectionModel, GridRenderCellParams, + GridLinkOperator, } from "@mui/x-data-grid-pro"; import { debounce } from "lodash"; import { useDispatch, useSelector } from "react-redux"; @@ -40,6 +41,10 @@ import { AppState } from "../../../store/types"; import { ContentItem } from "../../../services/types"; import moment from "moment"; import { getDateFilterFnByValues } from "../../Filters/DateFilter/getDateFilter"; +import { statusFilterOperator } from "./statusFilter"; +import { userFilterOperator } from "./userFilter"; +import { keywordSearchFilterOperator } from "./keywordSearchFilter"; +import { dateFilterOperator } from "./dateFilter"; const selectFilteredItems = ( state: AppState, @@ -156,6 +161,12 @@ export const FieldSelectorDialog = ({ const columns = useMemo(() => { let defaultCols: GridColumns = [ + // Column is only used for keyword search purposes + { + field: "keywordSearch", + hide: true, + filterOperators: [keywordSearchFilterOperator], + }, { field: "title", flex: 1, @@ -169,6 +180,11 @@ export const FieldSelectorDialog = ({ { field: "version", width: 60, + filterOperators: [ + userFilterOperator, + statusFilterOperator, + dateFilterOperator, + ], renderCell: (params: GridRenderCellParams) => ( ({ id: item.meta?.ZUID, + // Column is only used for keyword search purposes + keywordSearch: { + 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, + }, + }, image: { imageFieldName, itemZUID: item.meta?.ZUID, @@ -423,74 +471,6 @@ export const FieldSelectorDialog = ({ } }); - // Keyword search - if (!!filterKeyword) { - const search = filterKeyword.toLowerCase(); - - _rows = _rows?.filter((row) => { - const matchedUser = users.find( - (user) => user.ZUID === row?.item?.meta?.createdByUserZUID - ); - const creator = matchedUser - ? `${matchedUser.firstName} ${matchedUser.lastName}` - : null; - - return ( - Object.values(row?.item.data).some((value: any) => { - if (!value) return false; - - if (value?.filename || value?.title) { - return ( - value?.filename?.toLowerCase()?.includes(search) || - value?.title?.toLowerCase()?.includes(search) - ); - } - - return value.toString().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) - ); - }); - } - - // Filtering - if (filters.status) { - _rows = _rows?.filter((item) => { - 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]); @@ -600,101 +580,147 @@ export const FieldSelectorDialog = ({ }, }} > - {!rows?.length && isFilteringResults ? ( - { - 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 - /> - ) : ( - { - let _newSelectionModel = newSelectionModel as string[]; - - if (!multiselect && _newSelectionModel?.length > 1) { - _newSelectionModel = [_newSelectionModel[0]]; - } - - setSelectionModel([ - ...deletedItemZUIDs, - ..._newSelectionModel, - ]); - }} - sx={{ - bgcolor: "background.paper", - - "& .MuiDataGrid-columnHeaders": { - borderBottom: 0, - }, - - "& .MuiDataGrid-cellCheckbox": { - mx: "3px", - }, - - "& .MuiDataGrid-row.Mui-selected": { - borderBottom: (theme) => - `1px solid ${theme.palette.primary.main}`, - - "& .MuiDataGrid-cell": { - borderBottom: 0, - }, + { + let _newSelectionModel = newSelectionModel as string[]; + + if (!multiselect && _newSelectionModel?.length > 1) { + _newSelectionModel = [_newSelectionModel[0]]; + } + + setSelectionModel([...deletedItemZUIDs, ..._newSelectionModel]); + }} + components={{ + NoResultsOverlay: () => ( + { + 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 + /> + ), + }} + sx={{ + bgcolor: "background.paper", - "& .MuiDataGrid-cell:focus-within": { - outline: "none", - }, + "& .MuiDataGrid-columnHeaders": { + borderBottom: 0, + }, - ".MuiDataGrid-row": { - cursor: "pointer", - }, + "& .MuiDataGrid-cellCheckbox": { + mx: "3px", + }, - "& [data-field='image']": { - p: 0, - }, + "& .MuiDataGrid-row.Mui-selected": { + borderBottom: (theme) => + `1px solid ${theme.palette.primary.main}`, - "& [data-field='title']": { - pl: !!imageFieldName ? 2 : 0, - pr: 2, - }, - - "& [data-field='version']": { - pl: 0, - pr: 2, - justifyContent: "center", + "& .MuiDataGrid-cell": { + borderBottom: 0, }, - }} - /> - )} + }, + + "& .MuiDataGrid-cell:focus-within": { + outline: "none", + }, + + ".MuiDataGrid-row": { + cursor: "pointer", + }, + + "& [data-field='image']": { + p: 0, + }, + + "& [data-field='title']": { + pl: !!imageFieldName ? 2 : 0, + pr: 2, + }, + + "& [data-field='version']": { + pl: 0, + pr: 2, + justifyContent: "center", + }, + + // Makes sure that the custom overlay is interactive + "& [data-cy='NoSearchResults']": { + pointerEvents: "all", + }, + }} + /> )} diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/keywordSearchFilter.ts b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/keywordSearchFilter.ts new file mode 100644 index 000000000..5135510ea --- /dev/null +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/keywordSearchFilter.ts @@ -0,0 +1,51 @@ +import { GridFilterOperator } from "@mui/x-data-grid-pro"; +import moment from "moment"; + +export const keywordSearchFilterOperator: GridFilterOperator = { + label: "contains", + value: "keywordContains", + getApplyFilterFn: (filterItem) => { + if (!filterItem.value) { + return null; + } + + return (params): boolean => { + const row = params.row; + const searchValue = filterItem.value.toLowerCase(); + + // Check title + const title = row.title; + const titleMatch = + title?.primary?.toLowerCase().includes(searchValue) || + title?.secondary?.toLowerCase().includes(searchValue) || + false; + + // Check version + const version = row.version; + const createdBy = version?.itemData?.createdByName?.toLowerCase() || ""; + const publishedBy = + version?.publishData?.publishedByName?.toLowerCase() || ""; + const scheduledBy = + version?.scheduleData?.scheduledByName?.toLowerCase() || ""; + const createdAt = version?.itemData?.meta?.createdAt + ? moment(version.itemData.meta.createdAt) + .format("MMM D, YYYY h:mm A") + .toLowerCase() + : ""; + const updatedAt = version?.itemData?.meta?.updatedAt + ? moment(version.itemData.meta.updatedAt) + .format("MMM D, YYYY h:mm A") + .toLowerCase() + : ""; + + const versionMatch = + createdBy.includes(searchValue) || + publishedBy.includes(searchValue) || + scheduledBy.includes(searchValue) || + createdAt.includes(searchValue) || + updatedAt.includes(searchValue); + + return titleMatch || versionMatch; + }; + }, +}; diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/statusFilter.ts b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/statusFilter.ts new file mode 100644 index 000000000..158aca3b7 --- /dev/null +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/statusFilter.ts @@ -0,0 +1,31 @@ +import { GridFilterOperator } from "@mui/x-data-grid-pro"; + +export const statusFilterOperator: GridFilterOperator = { + label: "equals", + value: "statusEquals", + getApplyFilterFn: (filterItem) => { + if (!filterItem.value) { + return null; + } + + return (params): boolean => { + const version = params.value; + const status = filterItem.value; + + if (status === "published") { + return ( + version?.itemData?.publishing?.publishAt && + !version?.itemData?.scheduling?.publishAt + ); + } else if (status === "scheduled") { + return version?.itemData?.scheduling?.publishAt; + } else if (status === "notPublished") { + return ( + !version?.itemData?.publishing?.publishAt && + !version?.itemData?.scheduling?.publishAt + ); + } + return true; + }; + }, +}; diff --git a/src/shell/components/RelationalFieldBase/FieldSelectorDialog/userFilter.ts b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/userFilter.ts new file mode 100644 index 000000000..731a483ca --- /dev/null +++ b/src/shell/components/RelationalFieldBase/FieldSelectorDialog/userFilter.ts @@ -0,0 +1,16 @@ +import { GridFilterOperator } from "@mui/x-data-grid-pro"; + +export const userFilterOperator: GridFilterOperator = { + label: "equals", + value: "userEquals", + getApplyFilterFn: (filterItem) => { + if (!filterItem.value) { + return null; + } + + return (params): boolean => { + const version = params.value; + return version?.itemData?.meta?.createdByUserZUID === filterItem.value; + }; + }, +};