diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index e6bf44494a..14e5e86dbb 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -213,6 +213,10 @@ describe("Actions in content editor", () => { }); cy.get("input[name=title]", { timeout: 5000 }).click().type(timestamp); + cy.getBySelector("metaDescription") + .find("textarea") + .first() + .type(timestamp); cy.getBySelector("CreateItemSaveButton").click(); cy.contains("Created Item", { timeout: 5000 }).should("exist"); diff --git a/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx b/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx index 9be5237114..7e1032e3e4 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx @@ -282,7 +282,7 @@ const FieldLabel = memo( {(!!customTooltip || settings?.settings?.tooltip) && ( diff --git a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx index 024b364f1e..56ab43a4ec 100644 --- a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx +++ b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx @@ -878,7 +878,7 @@ export const MediaItem = ({ }} > {!isURL && !isBynderAsset && ( - <> + { event.stopPropagation(); @@ -914,7 +914,7 @@ export const MediaItem = ({ Copy ZUID - + )} { diff --git a/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx b/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx index 50a6889c6a..d0d789e270 100644 --- a/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx +++ b/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx @@ -44,12 +44,20 @@ export default memo(function PendingEditsModal(props: PendingEditsModalProps) { switch (action) { case "save": setLoading(true); - props.onSave().then(() => { - setLoading(false); - setOpen(false); - // @ts-ignore - answer(true); - }); + props + .onSave() + .then((i) => { + // @ts-ignore + answer(true); + }) + .catch((err) => { + // @ts-ignore + answer(false); + }) + .finally(() => { + setLoading(false); + setOpen(false); + }); break; case "delete": setLoading(true); 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 7af2c7adbb..19003bef90 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 } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import useIsMounted from "ismounted"; import { useHistory, useParams } from "react-router-dom"; @@ -81,6 +81,7 @@ export const ItemCreate = () => { const [fieldErrors, setFieldErrors] = useState({}); const [saveClicked, setSaveClicked] = useState(false); const [hasSEOErrors, setHasSEOErrors] = useState(false); + const metaRef = useRef(null); const [ createPublishing, @@ -157,6 +158,7 @@ export const ItemCreate = () => { const save = async (action: ActionAfterSave) => { setSaveClicked(true); + metaRef.current?.validateMetaFields?.(); if (hasErrors || hasSEOErrors) return; setSaving(true); @@ -385,6 +387,7 @@ export const ItemCreate = () => { setHasSEOErrors(hasErrors); }} isSaving={saving} + ref={metaRef} /> 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 a16a79b9fb..6553fba849 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js @@ -5,6 +5,7 @@ import { Redirect, useParams, useHistory, + useLocation, } from "react-router-dom"; import useIsMounted from "ismounted"; import { useDispatch, useSelector } from "react-redux"; @@ -70,6 +71,7 @@ export default function ItemEdit() { const dispatch = useDispatch(); const history = useHistory(); const isMounted = useIsMounted(); + const location = useLocation(); const { modelZUID, itemZUID } = useParams(); const item = useSelector((state) => state.content[itemZUID]); const items = useSelector((state) => state.content); @@ -87,6 +89,7 @@ export default function ItemEdit() { const [saveClicked, setSaveClicked] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); const [hasSEOErrors, setHasSEOErrors] = useState(false); + const [headerTitle, setHeaderTitle] = useState(""); const { data: fields, isLoading: isLoadingFields } = useGetContentModelFieldsQuery(modelZUID); const [showDuoModeLS, setShowDuoModeLS] = useLocalStorage( @@ -130,6 +133,12 @@ export default function ItemEdit() { }; }, [modelZUID, itemZUID]); + useEffect(() => { + if (!loading) { + setHeaderTitle(item?.web?.metaTitle || item?.web?.metaLinkText || ""); + } + }, [loading]); + const hasErrors = useMemo(() => { const hasErrors = Object.values(fieldErrors) ?.map((error) => { @@ -234,7 +243,15 @@ export default function ItemEdit() { setSaving(true); try { - const res = await dispatch(saveItem(itemZUID)); + // Skip content item fields validation when in the meta tab since this + // means that the user only wants to update the meta fields + const res = await dispatch( + saveItem({ + itemZUID, + skipContentItemValidation: + location?.pathname?.split("/")?.pop() === "meta", + }) + ); if (res.err === "VALIDATION_ERROR") { const missingRequiredFieldNames = res.missingRequired?.reduce( (acc, curr) => { @@ -302,7 +319,7 @@ export default function ItemEdit() { } setFieldErrors(errors); - return; + throw new Error(errors); } if (res.status === 400) { dispatch( @@ -311,9 +328,10 @@ export default function ItemEdit() { kind: "error", }) ); - return; + throw new Error(`Cannot Save: ${item.web.metaTitle}`); } + setHeaderTitle(item?.web?.metaTitle || item?.web?.metaLinkText || ""); dispatch( notify({ message: `Item Saved: ${ @@ -326,6 +344,7 @@ export default function ItemEdit() { dispatch(fetchAuditTrailDrafting(itemZUID)); } catch (err) { console.error(err); + throw new Error(err); // we need to set the item to dirty again because the save failed dispatch({ type: "MARK_ITEM_DIRTY", @@ -403,6 +422,7 @@ export default function ItemEdit() { onSave={save} saving={saving} hasError={Object.keys(fieldErrors)?.length} + headerTitle={headerTitle} /> (); const item = useSelector((state: AppState) => state.content[itemZUID]); + const [showAll, setShowAll] = useState(false); const contentAndMetaWordMatches = useMemo(() => { const textMetaFieldNames = [ @@ -70,15 +72,26 @@ export const MatchedWords = ({ Content and Meta Matched Words - {contentAndMetaWordMatches?.map((word) => ( + {contentAndMetaWordMatches + ?.slice(0, showAll ? undefined : 9) + ?.map((word) => ( + } + variant="outlined" + /> + ))} + {contentAndMetaWordMatches?.length > 10 && ( } variant="outlined" + icon={showAll ? : } + onClick={() => setShowAll(!showAll)} /> - ))} + )} ); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx index 37dcdb97ff..7db48db9da 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx @@ -36,13 +36,7 @@ export const GooglePreview = ({}: GooglePreviewProps) => { let path: string[] = [domain]; if (parent) { - path = [ - ...path, - ...(parent.web?.path?.split("/") || []), - item?.web?.pathPart, - ]; - } else { - path = [...path, item?.web?.pathPart]; + path = [...path, ...(parent.web?.path?.split("/") || [])]; } // Remove empty strings diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx index 386d07849d..57558b1460 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx @@ -1,4 +1,11 @@ -import { useState, useCallback, useMemo, useEffect } from "react"; +import { + useState, + useCallback, + useMemo, + useEffect, + forwardRef, + useImperativeHandle, +} from "react"; import { Stack, Box, Typography, ThemeProvider, Divider } from "@mui/material"; import { theme } from "@zesty-io/material"; import { useParams, useLocation } from "react-router"; @@ -9,6 +16,7 @@ import { useGetContentModelQuery } from "../../../../../../../shell/services/ins import { AppState } from "../../../../../../../shell/store/types"; import { Error } from "../../../components/Editor/Field/FieldShell"; import { fetchGlobalItem } from "../../../../../../../shell/store/content"; +import { Web } from "../../../../../../../shell/services/types"; // Fields import { MetaImage } from "./settings/MetaImage"; @@ -34,209 +42,250 @@ const REQUIRED_FIELDS = [ "metaDescription", "parentZUID", "pathPart", -] as const; +]; type Errors = Record; type MetaProps = { isSaving: boolean; onUpdateSEOErrors: (hasErrors: boolean) => void; }; -export const Meta = ({ isSaving, onUpdateSEOErrors }: MetaProps) => { - const dispatch = useDispatch(); - const location = useLocation(); - const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; - const { modelZUID, itemZUID } = useParams<{ - modelZUID: string; - itemZUID: string; - }>(); - const { data: model } = useGetContentModelQuery(modelZUID, { - skip: !modelZUID, - }); - const { meta, data, web } = useSelector( - (state: AppState) => - state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] - ); - const [errors, setErrors] = useState({}); - - // @ts-expect-error untyped - const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []); - - const handleOnChange = useCallback( - (value, name) => { - if (!name) { - throw new Error("Input is missing name attribute"); - } +export const Meta = forwardRef( + ({ isSaving, onUpdateSEOErrors }: MetaProps, ref) => { + const dispatch = useDispatch(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const { data: model } = useGetContentModelQuery(modelZUID, { + skip: !modelZUID, + }); + const { meta, data, web } = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const [errors, setErrors] = useState({}); - const currentErrors = cloneDeep(errors); + // @ts-expect-error untyped + const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []); - if (REQUIRED_FIELDS.includes(name)) { - currentErrors[name] = { - ...currentErrors?.[name], - MISSING_REQUIRED: !value, - }; - } + const handleOnChange = useCallback( + (value, name) => { + if (!name) { + throw new Error("Input is missing name attribute"); + } + + const currentErrors = cloneDeep(errors); + + if (REQUIRED_FIELDS.includes(name)) { + currentErrors[name] = { + ...currentErrors?.[name], + MISSING_REQUIRED: !value, + }; + } + + if (MaxLengths[name]) { + currentErrors[name] = { + ...currentErrors?.[name], + EXCEEDING_MAXLENGTH: + value?.length > MaxLengths[name] + ? value?.length - MaxLengths[name] + : 0, + }; + } + + setErrors(currentErrors); + + dispatch({ + // The og_image is stored as an ordinary field item and not a SEO field item + type: name === "og_image" ? "SET_ITEM_DATA" : "SET_ITEM_WEB", + itemZUID: meta?.ZUID, + key: name, + value: value, + }); + }, + [meta?.ZUID, errors] + ); + + useImperativeHandle( + ref, + () => { + return { + validateMetaFields() { + const currentErrors = cloneDeep(errors); + + REQUIRED_FIELDS.forEach((fieldName) => { + // @ts-expect-error + const value = web[fieldName]; - if (MaxLengths[name]) { - currentErrors[name] = { - ...currentErrors?.[name], - EXCEEDING_MAXLENGTH: - value?.length > MaxLengths[name] - ? value?.length - MaxLengths[name] - : 0, + currentErrors[fieldName] = { + ...currentErrors?.[fieldName], + MISSING_REQUIRED: !value, + }; + }); + + Object.keys(MaxLengths).forEach((fieldName) => { + // @ts-expect-error + const value = web[fieldName]; + + currentErrors[fieldName] = { + ...currentErrors?.[fieldName], + EXCEEDING_MAXLENGTH: + value?.length > MaxLengths[fieldName] + ? value?.length - MaxLengths[fieldName] + : 0, + }; + }); + + setTimeout(() => { + setErrors(currentErrors); + }); + }, }; + }, + [errors, web] + ); + + useEffect(() => { + if (isSaving) { + setErrors({}); + return; } + }, [isSaving]); - setErrors(currentErrors); - - dispatch({ - // The og_image is stored as an ordinary field item and not a SEO field item - type: name === "og_image" ? "SET_ITEM_DATA" : "SET_ITEM_WEB", - itemZUID: meta?.ZUID, - key: name, - value: value, - }); - }, - [meta?.ZUID, errors] - ); - - useEffect(() => { - if (isSaving) { - setErrors({}); - return; - } - }, [isSaving]); - - useEffect(() => { - const hasErrors = Object.values(errors) - ?.map((error) => { - return Object.values(error) ?? []; - }) - ?.flat() - .some((error) => !!error); - - onUpdateSEOErrors(hasErrors); - }, [errors]); - - return ( - - - - - - - SEO & Open Graph Settings - - - Specify this page's title and description. You can see how - they'll look in search engine results pages (SERPs) and social - media content in the preview on the right. - - - - - - - {model?.type !== "dataset" && web?.pathPart !== "zesty_home" && ( + useEffect(() => { + const hasErrors = Object.values(errors) + ?.map((error) => { + return Object.values(error) ?? []; + }) + ?.flat() + .some((error) => !!error); + + onUpdateSEOErrors(hasErrors); + }, [errors]); + + return ( + + + - URL Settings + SEO & Open Graph Settings - Define the URL of your web page + Specify this page's title and description. You can see how + they'll look in search engine results pages (SERPs) and social + media content in the preview on the right. - - + { - setErrors({ - ...errors, - [name]: { - ...errors?.[name], - ...error, - }, - }); - }} + error={errors?.metaDescription} /> + - )} - - - - Advanced Settings - - - Optimize your content item's SEO further - - - {model?.type !== "dataset" && ( - <> - + + + URL Settings + + + Define the URL of your web page + + + + { + setErrors((errors) => ({ + ...errors, + [name]: { + ...errors?.[name], + ...error, + }, + })); + }} /> - {!!web && ( - + )} + + + + Advanced Settings + + + Optimize your content item's SEO further + + + {model?.type !== "dataset" && ( + <> + - )} - - )} - - + {!!web && ( + + )} + + )} + + + + {model?.type !== "dataset" && !isCreateItemPage && ( + + + + + + )} - {model?.type !== "dataset" && !isCreateItemPage && ( - - - - - - )} - - - ); -}; + + ); + } +); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx index b0c3e64efd..b27e2a4423 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx @@ -113,7 +113,6 @@ export const ItemRoute = ({ // Replace ampersand characters with 'and' // Only allow alphanumeric characters const path = evt.target.value - .trim() .toLowerCase() .replace(/\&/g, "and") .replace(/[^a-zA-Z0-9]/g, "-"); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx index 78653544d4..5f89c8971b 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx @@ -82,8 +82,14 @@ type HeaderProps = { saving: boolean; onSave: () => void; hasError: boolean; + headerTitle: string; }; -export const ItemEditHeader = ({ saving, onSave, hasError }: HeaderProps) => { +export const ItemEditHeader = ({ + saving, + onSave, + hasError, + headerTitle, +}: HeaderProps) => { const { modelZUID, itemZUID } = useParams<{ modelZUID: string; itemZUID: string; @@ -138,7 +144,7 @@ export const ItemEditHeader = ({ saving, onSave, hasError }: HeaderProps) => { overflow: "hidden", }} > - {item?.web?.metaTitle || item?.web?.metaLinkText} + {headerTitle || ""} diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 6a7fa247a1..45bb26096a 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -379,7 +379,11 @@ export function fetchItems(modelZUID, options = {}) { // }; // } -export function saveItem(itemZUID, action = "") { +export function saveItem({ + itemZUID, + action = "", + skipContentItemValidation = false, +}) { return (dispatch, getState) => { const state = getState(); const item = cloneDeep(state.content[itemZUID]); @@ -431,12 +435,15 @@ export function saveItem(itemZUID, action = "") { item.data[field.name] > field.settings?.maxValue) ); + // When skipContentItemValidation is true, this means that only the + // SEO meta tags were changed, so we skip validating the content item if ( - missingRequired?.length || - lackingCharLength?.length || - regexPatternMismatch?.length || - regexRestrictPatternMatch?.length || - invalidRange?.length + !skipContentItemValidation && + (missingRequired?.length || + lackingCharLength?.length || + regexPatternMismatch?.length || + regexRestrictPatternMatch?.length || + invalidRange?.length) ) { return Promise.resolve({ err: "VALIDATION_ERROR", @@ -546,6 +553,11 @@ export function createItem(modelZUID, itemZUID) { return false; }); + const hasMissingRequiredSEOFields = + !item?.web?.metaTitle || + !item?.web?.metaDescription || + !item?.web?.pathPart; + // Check minlength is satisfied const lackingCharLength = fields?.filter( (field) => @@ -583,7 +595,8 @@ export function createItem(modelZUID, itemZUID) { lackingCharLength?.length || regexPatternMismatch?.length || regexRestrictPatternMatch?.length || - invalidRange?.length + invalidRange?.length || + hasMissingRequiredSEOFields ) { return Promise.resolve({ err: "VALIDATION_ERROR",