From 7b5c24225dfa86db97e448575a55926d674e1a84 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 18:11:25 -0700 Subject: [PATCH] Beta Release (#2981) Created by Github action --------- Co-authored-by: Nar -- <28705606+finnar-bin@users.noreply.github.com> Co-authored-by: Andres Galindo Co-authored-by: Stuart Runyan Co-authored-by: Allen Pigar <50983144+allenpigar@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gian Espinosa Co-authored-by: Gian Espinosa <44116036+glespinosa@users.noreply.github.com> --- cypress/e2e/content/actions.spec.js | 63 ++ cypress/e2e/content/item-list-table.spec.js | 4 +- cypress/e2e/content/meta.spec.js | 47 +- cypress/e2e/schema/field.spec.js | 2 +- public/images/openai-badge.svg | 13 + .../src/app/components/Editor/Editor.js | 20 +- .../src/app/components/Editor/Field/Field.tsx | 14 +- .../src/app/views/ItemCreate/ItemCreate.tsx | 118 ++- .../app/views/ItemEdit/FreestyleWrapper.tsx | 11 +- .../src/app/views/ItemEdit/ItemEdit.js | 94 +- .../Meta/ContentInsights/WordCount.tsx | 2 +- .../src/app/views/ItemEdit/Meta/index.tsx | 195 +++- .../Meta/settings/MetaDescription.tsx | 29 +- .../ItemEdit/Meta/settings/MetaTitle.tsx | 29 +- .../app/views/ItemEdit/Meta/settings/util.ts | 2 + .../src/app/views/ItemEdit/PublishState.tsx | 18 +- .../components/AddFieldModal/DefaultValue.tsx | 2 +- .../AddFieldModal/views/FieldForm.tsx | 55 +- .../components/FieldTypeDateTime/util.ts | 9 +- src/shell/components/withAi/AIGenerator.tsx | 892 +++++++++++++++--- .../components/withAi/AIGeneratorProvider.tsx | 68 ++ src/shell/components/withAi/index.tsx | 304 +++--- 22 files changed, 1597 insertions(+), 394 deletions(-) create mode 100644 public/images/openai-badge.svg create mode 100644 src/shell/components/withAi/AIGeneratorProvider.tsx diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index 14e5e86dbb..49e151c509 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -213,6 +213,9 @@ describe("Actions in content editor", () => { }); cy.get("input[name=title]", { timeout: 5000 }).click().type(timestamp); + + cy.getBySelector("ManualMetaFlow").click(); + cy.getBySelector("metaDescription") .find("textarea") .first() @@ -271,4 +274,64 @@ describe("Actions in content editor", () => { // }).should("exist"); // // cy.contains("The item has been purged from the CDN cache", { timeout: 5000 }).should("exist"); // }); + + it("Creates a new content item using AI-generated data", () => { + cy.waitOn("/v1/content/models*", () => { + cy.waitOn("/v1/content/models/*/fields?showDeleted=true", () => { + cy.visit("/content/6-a1a600-k0b6f0/new"); + }); + }); + + cy.intercept("/ai").as("ai"); + cy.wait(5000); + + // Generate AI content for single line text + cy.get("#12-0c3934-8dz720").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AITopicField").type("biking"); + cy.getBySelector("AIAudienceField").type("young adults"); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AIApprove").click(); + + // Generate AI content for wysiwyg + cy.get("#12-717920-6z46t7").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AITopicField").type("biking"); + cy.getBySelector("AIAudienceField").type("young adults"); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AIApprove").click(); + + // Select AI-assisted metadata generation flow + cy.getBySelector("ManualMetaFlow").click(); + + // Generate AI content for meta title + cy.getBySelector("metaTitle").find("input").clear(); + cy.getBySelector("metaTitle").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AISuggestion1").click(); + cy.getBySelector("AIApprove").click(); + + // Generate AI content for meta description + cy.getBySelector("metaDescription") + .find("textarea[name='metaDescription']") + .clear({ force: true }); + cy.getBySelector("metaDescription").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AISuggestion1").click(); + cy.getBySelector("AIApprove").click(); + + cy.getBySelector("CreateItemSaveButton").click(); + + cy.contains("Created Item", { timeout: 5000 }).should("exist"); + }); }); diff --git a/cypress/e2e/content/item-list-table.spec.js b/cypress/e2e/content/item-list-table.spec.js index 64a4fbd6ce..3433144a39 100644 --- a/cypress/e2e/content/item-list-table.spec.js +++ b/cypress/e2e/content/item-list-table.spec.js @@ -8,6 +8,8 @@ describe("Content item list table", () => { cy.getBySelector("SingleRelationshipCell", { timeout: 10000 }) .first() - .contains("All Field Types"); + .contains( + "5 Tricks to Teach Your Pitbull: Fun & Easy Tips for You & Your Dog!" + ); }); }); diff --git a/cypress/e2e/content/meta.spec.js b/cypress/e2e/content/meta.spec.js index e26edf686b..55a27946b8 100644 --- a/cypress/e2e/content/meta.spec.js +++ b/cypress/e2e/content/meta.spec.js @@ -1,9 +1,11 @@ +const today = Date.now(); + describe("Content Meta", () => { - before(() => { - cy.waitOn("/v1/content/models*", () => { - cy.visit("/content/6-556370-8sh47g/7-b939a4-457q19/meta"); - }); - }); + // before(() => { + // cy.waitOn("/v1/content/models*", () => { + // cy.visit("/content/6-556370-8sh47g/7-b939a4-457q19/meta"); + // }); + // }); // skipping failing test in preparation for CI. it.skip("Modifies and saves Meta fields", () => { @@ -63,4 +65,39 @@ describe("Content Meta", () => { cy.get("#SaveItemButton").click(); cy.contains("Saved a new ").should("exist"); }); + + it("Does not validate meta description for dataset items", () => { + cy.waitOn("/v1/content/models*", () => { + cy.waitOn("/v1/env/nav", () => { + cy.waitOn("/v1/search/items*", () => { + cy.visit("/content/6-675028-84dq4s/new"); + }); + }); + }); + + cy.get("#12-7893a0-w4j9gk", { timeout: 5000 }).find("input").type(today); + cy.getBySelector("CreateItemSaveButton").click(); + cy.get("[data-cy=toast]").contains("Created Item"); + }); + + it("Does validate meta description for non-dataset items", () => { + cy.waitOn("/v1/content/models*", () => { + cy.waitOn("/v1/env/nav", () => { + cy.waitOn("/v1/search/items*", () => { + cy.visit("/content/6-556370-8sh47g/7-b939a4-457q19/meta"); + }); + }); + }); + + cy.getBySelector("metaDescription", { timeout: 10000 }) + .find("textarea") + .first() + .type("test"); + cy.getBySelector("metaDescription") + .find("textarea") + .first() + .type("{selectall}{del}"); + cy.get("#SaveItemButton").click(); + cy.getBySelector("FieldErrorsList").should("exist"); + }); }); diff --git a/cypress/e2e/schema/field.spec.js b/cypress/e2e/schema/field.spec.js index 92625bf5b0..b4a6cf4521 100644 --- a/cypress/e2e/schema/field.spec.js +++ b/cypress/e2e/schema/field.spec.js @@ -784,7 +784,7 @@ describe("Schema: Fields", () => { cy.getBySelector(SELECTORS.ADD_FIELD_MODAL_DEACTIVATE_REACTIVATE) .should("exist") .click(); - cy.getBySelector(SELECTORS.ADD_FIELD_MODAL_CLOSE).should("exist").click(); + cy.getBySelector(SELECTORS.SAVE_FIELD_BUTTON).should("exist").click(); cy.wait("@updateField"); cy.wait("@getFields"); diff --git a/public/images/openai-badge.svg b/public/images/openai-badge.svg new file mode 100644 index 0000000000..cc72d9c62e --- /dev/null +++ b/public/images/openai-badge.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + 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 085d999727..defa5c92a5 100644 --- a/src/apps/content-editor/src/app/components/Editor/Editor.js +++ b/src/apps/content-editor/src/app/components/Editor/Editor.js @@ -11,6 +11,7 @@ import cx from "classnames"; import { AppLink } from "@zesty-io/core/AppLink"; import { ThemeProvider } from "@mui/material"; import { theme } from "@zesty-io/material"; +import { unescape } from "lodash"; import { Breadcrumbs } from "shell/components/global-tabs/components/Breadcrumbs"; import { Field } from "./Field"; import { FieldError } from "./FieldError"; @@ -276,14 +277,17 @@ export default memo(function Editor({ if (firstContentField && firstContentField.name === name) { // Remove tags and replace MS smart quotes with regular quotes - const cleanedValue = value - ?.replace(/<[^>]*>/g, "") - ?.replaceAll(/[\u2018\u2019\u201A]/gm, "'") - ?.replaceAll("’", "'") - ?.replaceAll(/[\u201C\u201D\u201E]/gm, '"') - ?.replaceAll("“", '"') - ?.replaceAll("”", '"') - ?.slice(0, 160); + const cleanedValue = unescape( + value + ?.replace(/<[^>]*>/g, "") + ?.replaceAll(/[\u2018\u2019\u201A]/gm, "'") + ?.replaceAll("’", "'") + ?.replaceAll(/[\u201C\u201D\u201E]/gm, '"') + ?.replaceAll("“", '"') + ?.replaceAll("”", '"') + ?.replaceAll(" ", " ") + ?.slice(0, 160) || "" + ); dispatch({ type: "SET_ITEM_WEB", 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 64c2e47f12..59ddaff608 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 @@ -282,6 +282,7 @@ export const Field = ({ case "text": return ( setEditorType(value)} + value={value} > { } message="Creating New Item" > - +
- - + {saveClicked && (hasErrors || hasSEOErrors) && ( { /> )} - { - setFieldErrors(errors); - }} - /> - - { - setSEOErrors(errors); - }} - isSaving={saving} - ref={metaRef} - errors={SEOErrors} - /> + + { + setFieldErrors(errors); + }} + /> + { + setSEOErrors(errors); + }} + isSaving={saving} + ref={metaRef} + errors={SEOErrors} + /> + - {model?.type !== "dataset" && } + {model?.type !== "dataset" && ( + <> + + + + )} - - + + {isScheduleDialogOpen && !isLoadingNewItem && ( { }; 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 398291f500..c64bfe5285 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js @@ -56,6 +56,7 @@ import { useLocalStorage } from "react-use"; import { FreestyleWrapper } from "./FreestyleWrapper"; import { Meta } from "./Meta"; import { FieldError } from "../../components/Editor/FieldError"; +import { AIGeneratorProvider } from "../../../../../../shell/components/withAi/AIGeneratorProvider"; const selectItemHeadTags = createSelector( (state) => state.headTags, @@ -477,7 +478,12 @@ export default function ItemEdit() { > save().catch((err) => console.error(err))} @@ -512,24 +518,26 @@ export default function ItemEdit() { exact path="/content/:modelZUID/:itemZUID/meta" render={() => ( - { - setSEOErrors(errors); - }} - isSaving={saving} - errors={SEOErrors} - errorComponent={ - saveClicked && - hasSEOErrors && ( - - ) - } - /> + + { + setSEOErrors(errors); + }} + isSaving={saving} + errors={SEOErrors} + errorComponent={ + saveClicked && + hasSEOErrors && ( + + ) + } + /> + )} /> ( - save().catch((err) => console.error(err))} - dispatch={dispatch} - loading={loading} - saving={saving} - saveClicked={saveClicked} - onUpdateFieldErrors={(errors) => { - setFieldErrors(errors); - }} - fieldErrors={fieldErrors} - hasErrors={hasErrors} - activeFields={activeFields} - fieldErrorRef={fieldErrorRef} - /> + + + save().catch((err) => console.error(err)) + } + dispatch={dispatch} + loading={loading} + saving={saving} + saveClicked={saveClicked} + onUpdateFieldErrors={(errors) => { + setFieldErrors(errors); + }} + fieldErrors={fieldErrors} + hasErrors={hasErrors} + activeFields={activeFields} + fieldErrorRef={fieldErrorRef} + /> + )} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx index 509527f0f3..61619ab16f 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx @@ -51,7 +51,7 @@ export const WordCount = ({ /> - Non Common Words + Non Filler Words {totalUniqueNonCommonWords} 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 cab0588882..81cf7d68f9 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 @@ -5,11 +5,23 @@ import { useEffect, forwardRef, useImperativeHandle, + useRef, } from "react"; -import { Stack, Box, Typography, ThemeProvider, Divider } from "@mui/material"; -import { theme } from "@zesty-io/material"; +import { + Stack, + Box, + Typography, + ThemeProvider, + Divider, + ListItemIcon, + ListItemText, + ListItemButton, +} from "@mui/material"; +import { Brain, theme } from "@zesty-io/material"; import { useParams, useLocation } from "react-router"; import { useSelector, useDispatch } from "react-redux"; +import { keyframes } from "@mui/system"; +import { EditRounded } from "@mui/icons-material"; import { cloneDeep } from "lodash"; import { ContentInsights } from "./ContentInsights"; @@ -43,6 +55,34 @@ import { TCTitle } from "./settings/TCTitle"; import { TCDescription } from "./settings/TCDescription"; import { FieldError } from "../../../components/Editor/FieldError"; +const rotateAnimation = keyframes` + 0% { + background-position: 0% 0%; + } + 100% { + background-position: 0% 100%; + } +`; +const FlowType = { + AIGenerated: "ai-generated", + Manual: "manual", +} as const; +const flowButtons = [ + { + flowType: FlowType.AIGenerated, + icon: , + primaryText: "Yes, improve with AI Meta Data Assistant", + secondaryText: + "Our AI will scan your content and generate your meta data for you", + }, + { + flowType: FlowType.Manual, + icon: , + primaryText: "No, I will improve and edit it myself", + secondaryText: + "Perfect if you already know what you want your Meta Data to be", + }, +]; export const MaxLengths: Record = { metaLinkText: 150, metaTitle: 150, @@ -84,6 +124,10 @@ export const Meta = forwardRef( (state: AppState) => state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] ); + const [flowType, setFlowType] = + useState(null); + const metaDescriptionButtonRef = useRef(null); + const metaTitleButtonRef = useRef(null); // @ts-expect-error untyped const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []); @@ -238,6 +282,11 @@ export const Meta = forwardRef( } setTimeout(() => { + // Makes sure that the user sees the error blurbs on each + // field when in the create item page + if (isCreateItemPage) { + setFlowType(FlowType.Manual); + } onUpdateSEOErrors(currentErrors); }); @@ -248,6 +297,9 @@ export const Meta = forwardRef( ?.flat() .some((error) => !!error); }, + triggerAIGeneratedFlow() { + setFlowType(FlowType.AIGenerated); + }, }; }, [errors, web, model, metaFields, data] @@ -260,12 +312,116 @@ export const Meta = forwardRef( } }, [isSaving]); + useEffect(() => { + if (!isCreateItemPage) return; + + if (flowType === FlowType.AIGenerated) { + // Immediately clear out the existing values for the meta title and description + dispatch({ + type: "SET_ITEM_WEB", + itemZUID: meta?.ZUID, + key: "metaTitle", + value: "", + }); + dispatch({ + type: "SET_ITEM_WEB", + itemZUID: meta?.ZUID, + key: "metaDescription", + value: "", + }); + + // Then trigger the AI assistant popup + metaTitleButtonRef.current?.triggerAIButton?.(); + } + }, [flowType, isCreateItemPage, meta?.ZUID]); + + if (isCreateItemPage && flowType === null) { + return ( + + + + + + Would you like to improve your Meta Title & Description? + + + Our AI Assistant will scan your content and improve your meta + title and description to help improve search engine + visibility.{" "} + + + {flowButtons.map((data) => ( + setFlowType(data.flowType)} + sx={{ + borderRadius: 2, + border: 1, + borderColor: "border", + backgroundColor: "common.white", + py: 2, + }} + > + {data.icon} + + {data.primaryText} + + } + disableTypography + sx={{ my: 0 }} + secondary={ + + {data.secondaryText} + + } + /> + + ))} + + + + ); + } + return ( - - + {!!errorComponent && errorComponent} @@ -289,14 +445,36 @@ export const Meta = forwardRef( { + if (flowType === FlowType.AIGenerated) { + console.log("reset on meta title"); + setFlowType(FlowType.Manual); + } + }} + onAIMetaTitleInserted={() => { + // Scroll to and open the meta description ai generator to continue + // with the AI-assisted flow + if (flowType === FlowType.AIGenerated) { + metaDescriptionButtonRef.current?.triggerAIButton?.(); + } + }} /> { + if (flowType === FlowType.AIGenerated) { + console.log("reset on meta description"); + setFlowType(FlowType.Manual); + } + }} + isAIAssistedFlow={flowType === FlowType.AIGenerated} required={REQUIRED_FIELDS.includes("metaDescription")} /> @@ -400,11 +578,10 @@ export const Meta = forwardRef( {!isCreateItemPage && ( )} - + ); } diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx index 59371cef31..8e9668f751 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, ChangeEvent } from "react"; import { connect, useDispatch } from "react-redux"; import { TextField, Box } from "@mui/material"; @@ -7,22 +7,34 @@ import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; import { MaxLengths } from ".."; import { hasErrors } from "./util"; import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { withAI } from "../../../../../../../../shell/components/withAi"; +import { MutableRefObject } from "react"; + +const AIFieldShell = withAI(FieldShell); type MetaDescriptionProps = { value: string; onChange: (value: string, name: string) => void; error: Error; + onResetFlowType: () => void; + aiButtonRef?: MutableRefObject; + isAIAssistedFlow: boolean; required: boolean; }; export default connect()(function MetaDescription({ value, onChange, error, + onResetFlowType, + aiButtonRef, + isAIAssistedFlow, required, }: MetaDescriptionProps) { return ( - ) => { + onChange(evt.target.value, "metaDescription"); + onResetFlowType?.(); + }} + onResetFlowType={() => { + onResetFlowType?.(); + }} + isAIAssistedFlow={isAIAssistedFlow} > - + ); }); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx index 1fbd3671f0..fc15fe43f6 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { ChangeEvent, memo, MutableRefObject } from "react"; import { TextField, Box } from "@mui/material"; @@ -6,20 +6,33 @@ import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; import { MaxLengths } from ".."; import { hasErrors } from "./util"; import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { withAI } from "../../../../../../../../shell/components/withAi"; + +const AIFieldShell = withAI(FieldShell); type MetaTitleProps = { value: string; onChange: (value: string, name: string) => void; error: Error; + saveMetaTitleParameters?: boolean; + onResetFlowType: () => void; + onAIMetaTitleInserted?: () => void; + aiButtonRef?: MutableRefObject; }; export const MetaTitle = memo(function MetaTitle({ value, onChange, error, + saveMetaTitleParameters, + onResetFlowType, + onAIMetaTitleInserted, + aiButtonRef, }: MetaTitleProps) { return ( - ) => { + onChange(evt.target.value, "metaTitle"); + onAIMetaTitleInserted?.(); + }} + onResetFlowType={() => { + onResetFlowType?.(); + }} > onChange(evt.target.value, "metaTitle")} error={hasErrors(error)} /> - + ); }); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts index 10d385d7b0..0aa2242464 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts @@ -9,6 +9,8 @@ export const hasErrors = (errors: Error) => { export const validateMetaDescription = (value: string) => { let message = ""; + if (!value) return message; + if (!(value.indexOf("\u0152") === -1)) { message = "Found OE ligature. These special characters are not allowed in meta descriptions."; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/PublishState.tsx b/src/apps/content-editor/src/app/views/ItemEdit/PublishState.tsx index 94353bc94d..c8e0c9a94d 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/PublishState.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/PublishState.tsx @@ -31,24 +31,12 @@ export const PublishState = ({ reloadItem }: Props) => { { field: "_active", headerName: "Status", - width: 110, + width: 120, renderCell: (value: GridValueGetterParams) => { if (new Date(value.row.publishAt) > new Date()) { - return ( - - ); + return ; } else if (value.row._active) { - return ( - - ); + return ; } else { return <>; } diff --git a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx index d0b6a5427a..0a4d2ac979 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx @@ -80,7 +80,7 @@ export const DefaultValue = ({ /> {isDefaultValueEnabled && ( - + { const [activeTab, setActiveTab] = useState("details"); const [isSubmitClicked, setIsSubmitClicked] = useState(false); + const [fieldStateOnSaveAction, setFieldStateOnSaveAction] = useState< + "deactivate" | "reactivate" + >(fieldData?.deletedAt ? "reactivate" : "reactivate"); const [isAddAnotherFieldClicked, setIsAddAnotherFieldClicked] = useState(false); const { mediaFoldersOptions } = useMediaRules(); @@ -650,7 +653,25 @@ export const FieldForm = ({ modelZUID: id, fieldZUID: fieldData.ZUID, body: updateBody, - }); + }) + .unwrap() + .then(() => { + // Update the field state after field changes are done + if (fieldStateOnSaveAction === "reactivate" && fieldData?.deletedAt) { + undeleteContentModelField({ + modelZUID: id, + fieldZUID: fieldData?.ZUID, + }); + } else if ( + fieldStateOnSaveAction === "deactivate" && + !fieldData?.deletedAt + ) { + deleteContentModelField({ + modelZUID: id, + fieldZUID: fieldData?.ZUID, + }); + } + }); } else { // We want to skip field cache invalidation when creating an in-between field // We'll let the bulk update rtk query do the invalidation after this call @@ -915,33 +936,25 @@ export const FieldForm = ({ + fieldStateOnSaveAction === "deactivate" ? ( + ) : ( - + ) } onClick={() => { - if (fieldData?.deletedAt) { - undeleteContentModelField({ - modelZUID: id, - fieldZUID: fieldData?.ZUID, - }); - } else { - deleteContentModelField({ - modelZUID: id, - fieldZUID: fieldData?.ZUID, - }); - } + setFieldStateOnSaveAction( + fieldStateOnSaveAction === "deactivate" + ? "reactivate" + : "deactivate" + ); }} loading={isDeletingField || isUndeletingField} > - {fieldData?.deletedAt + {fieldStateOnSaveAction === "deactivate" ? "Reactivate Field" : "Deactivate Field"} diff --git a/src/shell/components/FieldTypeDateTime/util.ts b/src/shell/components/FieldTypeDateTime/util.ts index 8c09bb8441..e6168b5132 100644 --- a/src/shell/components/FieldTypeDateTime/util.ts +++ b/src/shell/components/FieldTypeDateTime/util.ts @@ -1469,12 +1469,17 @@ export const TIMEZONES = [ }, ] as const; +// Specify exact ISO format. This ensures consistent parsing across different browsers. +const ISO_FORMAT = "MM/DD/YYYY HH:mm:ss.SSSSSS"; + export const toISOString = (timeString: string) => { - return moment(`01-01-2024 ${timeString}`).format("HH:mm:ss.SSSSSS"); + return moment(`01-01-2024 ${timeString}`, ISO_FORMAT).format( + "HH:mm:ss.SSSSSS" + ); }; export const to12HrTime = (isoTime: string) => { - return moment(`01/01/2024 ${isoTime}`).format("h:mm a"); + return moment(`01/01/2024 ${isoTime}`, ISO_FORMAT).format("h:mm a"); }; const generateTimeOptions = () => { diff --git a/src/shell/components/withAi/AIGenerator.tsx b/src/shell/components/withAi/AIGenerator.tsx index 6306cf650e..4b72c46f14 100644 --- a/src/shell/components/withAi/AIGenerator.tsx +++ b/src/shell/components/withAi/AIGenerator.tsx @@ -1,4 +1,11 @@ -import { useEffect, useState, useRef } from "react"; +import { + useEffect, + useState, + useRef, + useMemo, + useContext, + useReducer, +} from "react"; import { Button, Box, @@ -10,49 +17,225 @@ import { MenuItem, Autocomplete, CircularProgress, + Stack, + InputAdornment, + Tooltip, + alpha, + ListItemButton, } from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; import StopRoundedIcon from "@mui/icons-material/StopRounded"; import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; import RefreshRoundedIcon from "@mui/icons-material/RefreshRounded"; -import { useAiGenerationMutation } from "../../services/cloudFunctions"; -import { useGetLangsMappingQuery } from "../../services/instance"; +import LanguageRoundedIcon from "@mui/icons-material/LanguageRounded"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; import { Brain } from "@zesty-io/material"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; +import { useLocation, useParams } from "react-router"; + import { notify } from "../../store/notifications"; +import openAIBadge from "../../../../public/images/openai-badge.svg"; +import { FieldTypeNumber } from "../FieldTypeNumber"; +import { useAiGenerationMutation } from "../../services/cloudFunctions"; +import { + useGetContentModelFieldsQuery, + useGetLangsMappingQuery, +} from "../../services/instance"; +import { AppState } from "../../store/types"; +import { AIGeneratorContext } from "./AIGeneratorProvider"; + +const DEFAULT_LIMITS: Record = { + text: 150, + paragraph: 3, + word: 1500, + description: 160, + title: 150, +}; +export const TONE_OPTIONS = [ + { + value: "intriguing", + label: "Intriguing - Curious, mysterious, and thought-provoking", + }, + { + value: "professional", + label: "Professional - Serious, formal, and authoritative", + }, + { value: "playful", label: "Playful - Fun, light-hearted, and whimsical" }, + { + value: "sensational", + label: "Sensational - Bold, dramatic, and attention-grabbing", + }, + { value: "succint", label: "Succinct - Clear, factual, with no hyperbole" }, +] as const; +export type ToneOption = + | "intriguing" + | "professional" + | "playful" + | "sensational" + | "succint"; + +type FieldData = { + topic?: string; + audienceDescription: string; + tone: ToneOption; + keywords?: string; + limit?: number; + language: { + label: string; + value: string; + }; +}; +// description and title are used for seo meta title & description +type AIType = "text" | "paragraph" | "description" | "title" | "word"; interface Props { onApprove: (data: string) => void; - onClose: () => void; - aiType: string; + onClose: (reason: "close" | "insert") => void; + aiType: AIType; label: string; + fieldZUID: string; + isAIAssistedFlow: boolean; } -export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { +export const AIGenerator = ({ + onApprove, + onClose, + aiType, + label, + fieldZUID, + isAIAssistedFlow, +}: Props) => { const dispatch = useDispatch(); - const [topic, setTopic] = useState(""); - const [limit, setLimit] = useState(aiType === "text" ? "150" : "3"); - const request = useRef(null); - const [language, setLanguage] = useState({ - label: "English (United States)", - value: "en-US", + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const [hasFieldError, setHasFieldError] = useState(false); + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const { data: fields } = useGetContentModelFieldsQuery(modelZUID, { + skip: !modelZUID, }); - - const [data, setData] = useState(""); + const [selectedContent, setSelectedContent] = useState(null); + const request = useRef(null); + const [fieldData, updateFieldData] = useReducer( + (state: FieldData, action: Partial) => { + return { + ...state, + ...action, + }; + }, + { + topic: "", + audienceDescription: "", + tone: "professional", + keywords: "", + limit: DEFAULT_LIMITS[aiType], + language: { + label: "English (United States)", + value: "en-US", + }, + } + ); + const [data, setData] = useState([]); const { data: langMappings } = useGetLangsMappingQuery(); + const [ + lastOpenedZUID, + updateLastOpenedZUID, + parameterData, + updateParameterData, + ] = useContext(AIGeneratorContext); const [aiGenerate, { isLoading, isError, data: aiResponse }] = useAiGenerationMutation(); + const allTextFieldContent = useMemo(() => { + // This is really only needed for seo meta title & description + // so we skip it for other types + if ( + (aiType !== "title" && aiType !== "description") || + !fields?.length || + !Object.keys(item?.data)?.length + ) + return ""; + + const textFieldTypes = [ + "text", + "wysiwyg_basic", + "wysiwyg_advanced", + "article_writer", + "markdown", + "textarea", + ]; + + return fields.reduce((accu, curr) => { + if (!curr.deletedAt && textFieldTypes.includes(curr.datatype)) { + return (accu = `${accu} ${item.data[curr.name] || ""}`); + } + + return accu; + }, ""); + }, [fields, item?.data]); + const handleGenerate = () => { - request.current = aiGenerate({ - type: aiType, - length: limit, - phrase: topic, - lang: language.value, - }); + if (aiType === "description" || aiType === "title") { + request.current = aiGenerate({ + type: aiType, + lang: fieldData.language.value, + tone: fieldData.tone, + audience: fieldData.audienceDescription, + content: allTextFieldContent, + keywords: fieldData.keywords, + }); + } else { + if (fieldData.topic) { + request.current = aiGenerate({ + type: aiType, + length: fieldData.limit, + phrase: fieldData.topic, + lang: fieldData.language.value, + tone: fieldData.tone, + audience: fieldData.audienceDescription, + }); + } else { + setHasFieldError(true); + } + } }; + useEffect(() => { + // Used to automatically popuplate the data if they reopened the AI Generator + // on the same field or if the current field is the metaDescription field and + // is currently going through the AI assisted flow + if ( + lastOpenedZUID === fieldZUID || + (isAIAssistedFlow && fieldZUID === "metaDescription") + ) { + try { + const key = + isAIAssistedFlow && fieldZUID === "metaDescription" + ? "metaTitle" + : fieldZUID; + const { topic, audienceDescription, tone, keywords, limit, language } = + parameterData[key]; + + updateFieldData({ + topic, + audienceDescription, + tone, + ...(!!limit && { limit: limit }), + language, + keywords, + }); + } catch (err) { + console.error(err); + } + } + }, [parameterData, lastOpenedZUID, isAIAssistedFlow]); + useEffect(() => { if (isError) { dispatch( @@ -66,7 +249,24 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { useEffect(() => { if (aiResponse?.data) { - setData(aiResponse.data); + // For description and title, response will be a stringified array + if (aiType === "description" || aiType === "title") { + try { + const responseArr = JSON.parse(aiResponse.data); + + if (Array.isArray(responseArr)) { + const cleanedResponse = responseArr.map((response) => + response?.replace(/^"(.*)"$/, "$1") + ); + + setData(cleanedResponse); + } + } catch (err) { + console.error("Error parsing AI response: ", err); + } + } else { + setData([aiResponse.data.replace(/^"(.*)"$/, "$1")]); + } } }, [aiResponse]); @@ -77,147 +277,584 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { }) ); + const handleClose = (reason: "close" | "insert") => { + // Temporarily save all the inputs when closing the popup so + // that if they reopen it again, we can repopulate the fields + updateLastOpenedZUID(fieldZUID); + updateParameterData({ + [fieldZUID]: { + topic: fieldData.topic, + limit: fieldData.limit, + language: fieldData.language, + tone: fieldData.tone, + audienceDescription: fieldData.audienceDescription, + keywords: fieldData.keywords, + }, + }); + + // Reason is used to determine if the AI assisted flow will be cancelled + // or not + onClose(reason); + }; + + // Loading if (isLoading) { return ( - - - - + + + + + + + Generating + {aiType === "title" + ? " Meta Title" + : aiType === "description" + ? " Meta Description" + : " Content"} + + + {aiType === "title" + ? "Our AI assistant is scanning your content and generating your meta title " + : aiType === "description" + ? "Our AI assistant is scanning your content and generating your meta description " + : "Our AI assistant is generating your content "} + based on your parameters + + + + + ); + } + + // Meta Title and Meta Description field types + if (aiType === "title" || aiType === "description") { + return ( + + + + + theme.palette.common.white }} /> + + + + + + {!!data?.length ? "Select" : "Generate"} Meta{" "} + {aiType === "title" ? "Title" : "Description"} + + + {!!data?.length + ? `Select 1 out of the 3 Meta ${ + aiType === "title" ? "Titles" : "Descriptions" + } our AI has generated for you.` + : `Our AI will scan your content and generate your meta ${ + aiType === "title" ? "title" : "description" + } for you based on your parameters set below`} + + + + + {!!data?.length ? ( + + {data.map((value, index) => ( + setSelectedContent(index)} + sx={{ + borderRadius: 2, + border: 1, + borderColor: "border", + backgroundColor: "common.white", + px: 1.5, + py: 2, + flexDirection: "column", + alignItems: "flex-start", + + "&.Mui-selected": { + borderColor: "primary.main", + }, + }} + > + + OPTION {index + 1} + + + {String(value)} + + + ))} + + ) : ( + + + Describe your Audience + + updateFieldData({ audienceDescription: evt.target.value }) + } + placeholder="e.g. Freelancers, Designers, ....." + fullWidth + /> + + + + Keywords to Include (separated by commas) + + + updateFieldData({ keywords: evt.target.value }) + } + placeholder="e.g. Hikes, snow" + fullWidth + /> + + + + Tone + + + + + + option.value === value.value + } + onChange={(_, value) => + updateFieldData({ tone: value.value }) + } + value={TONE_OPTIONS.find( + (option) => option.value === fieldData.tone + )} + options={TONE_OPTIONS} + renderInput={(params: any) => ( + + )} + /> + + + + Language + + + + + + option.value === value.value + } + onChange={(event, value) => + updateFieldData({ language: value }) + } + value={fieldData.language as any} + options={languageOptions} + renderInput={(params: any) => ( + + + + ), + }} + /> + )} + slotProps={{ + paper: { + sx: { + maxHeight: 300, + }, + }, + }} + /> + + + )} - - Generating Content - - - + + {!!data?.length ? ( + + + + + ) : ( + + )} + + ); } + // Content item field types return ( - - + `1px solid ${theme.palette.border}`, - }} + p={2.25} + gap={1.5} + bgcolor="background.paper" + borderRadius="2px 2px 0 0" > - - - - {data ? "Your Content is Generated!" : "Generate Content"} + + + theme.palette.common.white }} /> + + + + + + {!!data?.length ? "Your Content is Generated!" : "Generate Content"} - - - - - + + {!!data?.length + ? "Our AI assistant can make mistakes. Please check important info." + : "Use our AI assistant to write content for you"} + + + `1px solid ${theme.palette.border}`, + overflowY: "auto", + borderTop: 1, + borderBottom: 1, + borderColor: "border", }} > - {data ? ( + {!!data?.length ? ( - {label} + Generated Content setData(event.target.value)} + value={data[0]} + onChange={(event) => setData([event.target.value])} multiline - rows={8} + rows={15} fullWidth /> ) : ( - <> - Topic - - Describe what you want the AI to write for you - - setTopic(event.target.value)} - placeholder={`e.g. "Hikes in Washington"`} - multiline - rows={2} - fullWidth - /> - - - {aiType === "text" && ( - <> - Character Limit - setLimit(event.target.value)} - fullWidth - /> - + + + Topic * + { + if (!!event.target.value) { + setHasFieldError(false); + } + + updateFieldData({ topic: event.target.value }); + }} + placeholder={`e.g. Hikes in Washington`} + multiline + rows={3} + fullWidth + error={hasFieldError} + helperText={ + hasFieldError && + "This is field is required. Please enter a value." + } + /> + + + Describe your Audience + + updateFieldData({ audienceDescription: evt.target.value }) + } + placeholder="e.g. Freelancers, Designers, ....." + fullWidth + /> + + + + Tone + + + + + + option.value === value.value + } + onChange={(_, value) => updateFieldData({ tone: value.value })} + value={TONE_OPTIONS.find( + (option) => option.value === fieldData.tone )} - {aiType === "paragraph" && ( - <> - Paragraph Limit - - + options={TONE_OPTIONS} + renderInput={(params: any) => ( + )} + /> + + + + + + {aiType === "text" && "Character"} + {aiType === "paragraph" && "Word"} Limit + + + + + + updateFieldData({ limit: value })} + hasError={false} + /> - - Language + + + Language + + + + option.value === value.value } - onChange={(event, value) => setLanguage(value)} - value={language as any} + onChange={(event, value) => + updateFieldData({ language: value }) + } + value={fieldData.language as any} options={languageOptions} renderInput={(params: any) => ( - + + + + ), + }} + /> )} + slotProps={{ + paper: { + sx: { + maxHeight: 300, + }, + }, + }} /> - - + + )} - - - {data ? ( + {!!data?.length ? ( ) : ( @@ -245,12 +882,11 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { data-cy="AIGenerate" variant="contained" onClick={handleGenerate} - disabled={!topic} > Generate )} - + ); }; diff --git a/src/shell/components/withAi/AIGeneratorProvider.tsx b/src/shell/components/withAi/AIGeneratorProvider.tsx new file mode 100644 index 0000000000..454832ce17 --- /dev/null +++ b/src/shell/components/withAi/AIGeneratorProvider.tsx @@ -0,0 +1,68 @@ +import { + Dispatch, + createContext, + useReducer, + useState, + ReactNode, +} from "react"; + +import { ToneOption } from "./AIGenerator"; + +type ParameterData = { + topic?: string; + audienceDescription: string; + tone: ToneOption; + keywords?: string; + limit?: number; + language: { + label: string; + value: string; + }; +}; +type AIGeneratorContextType = [ + string | null, + Dispatch, + Record, + Dispatch> +]; + +export const AIGeneratorContext = createContext([ + null, + () => {}, + null, + () => {}, +]); + +type AIGeneratorProviderProps = { + children?: ReactNode; +}; +export const AIGeneratorProvider = ({ children }: AIGeneratorProviderProps) => { + const [lastOpenedItem, setLastOpenedItem] = useState(null); + // const [parameterData, setParameterData] = useState>(null); + const [parameterData, updateParameterData] = useReducer( + ( + state: Record, + action: Record + ) => { + const newState = { + ...state, + ...action, + }; + return newState; + }, + {} + ); + + return ( + + {children} + + ); +}; diff --git a/src/shell/components/withAi/index.tsx b/src/shell/components/withAi/index.tsx index ce93f8a519..2681deee23 100644 --- a/src/shell/components/withAi/index.tsx +++ b/src/shell/components/withAi/index.tsx @@ -1,13 +1,24 @@ -import { memo, useMemo } from "react"; -import { Popover, IconButton } from "@mui/material"; +import { useRef, forwardRef, useImperativeHandle } from "react"; +import { Popover, Button, IconButton, alpha } from "@mui/material"; import { Brain, theme } from "@zesty-io/material"; import { ThemeProvider } from "@mui/material/styles"; import { ComponentType, MouseEvent, useState } from "react"; -import { AIGenerator } from "./AIGenerator"; import { useSelector } from "react-redux"; -import { AppState } from "../../store/types"; +import { keyframes } from "@mui/system"; import moment from "moment-timezone"; + +import { AppState } from "../../store/types"; import instanceZUID from "../../../utility/instanceZUID"; +import { AIGenerator, TONE_OPTIONS } from "./AIGenerator"; + +const rotateAnimation = keyframes` + 0% { + background-position: 0% 0%; + } + 100% { + background-position: 0% 100%; + } +`; // This date is used determine if the AI feature is enabled const enabledDate = "2023-01-13"; @@ -26,109 +37,188 @@ const paragraphFormat = (text: string) => { .join("

")}

`; }; -export const withAI = (WrappedComponent: ComponentType) => (props: any) => { - const instanceCreatedAt = useSelector( - (state: AppState) => state.instance.createdAt - ); - const isEnabled = - moment(instanceCreatedAt).isSameOrAfter(moment(enabledDate)) || - enabledZUIDs.includes(instanceZUID); - const [anchorEl, setAnchorEl] = useState(null); - const [focused, setFocused] = useState(false); - const [key, setKey] = useState(0); - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const handleApprove = (generatedText: string) => { - if ( - props.datatype === "article_writer" || - props.datatype === "markdown" || - props.datatype === "wysiwyg_advanced" || - props.datatype === "wysiwyg_basic" - ) { - props.onChange( - `${props.value || ""}${ - props.datatype === "markdown" - ? generatedText - : paragraphFormat(generatedText) - }`, - props.name, - props.datatype +export const withAI = (WrappedComponent: ComponentType) => + forwardRef((props: any, ref) => { + const instanceCreatedAt = useSelector( + (state: AppState) => state.instance.createdAt + ); + const isEnabled = + moment(instanceCreatedAt).isSameOrAfter(moment(enabledDate)) || + enabledZUIDs.includes(instanceZUID); + const [anchorEl, setAnchorEl] = useState(null); + const [focused, setFocused] = useState(false); + const [key, setKey] = useState(0); + const aiButtonRef = useRef(null); + + useImperativeHandle( + ref, + () => { + return { + triggerAIButton() { + if (!anchorEl) { + aiButtonRef.current?.scrollIntoView({ behavior: "smooth" }); + + // Makes sure that the popup is placed correctly after + // the scrollIntoView function is ran + setTimeout(() => { + aiButtonRef.current?.click(); + }, 500); + } + }, + }; + }, + [] + ); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = (reason: "close" | "insert") => { + if ( + reason === "close" || + (reason === "insert" && props.ZUID === "metaDescription") + ) { + // Reset the meta details flow type + props.onResetFlowType?.(); + } + setAnchorEl(null); + }; + + const handleApprove = (generatedText: string) => { + if ( + props.datatype === "article_writer" || + props.datatype === "markdown" || + props.datatype === "wysiwyg_advanced" || + props.datatype === "wysiwyg_basic" + ) { + props.onChange( + `${props.value || ""}${ + props.datatype === "markdown" + ? generatedText + : paragraphFormat(generatedText) + }`, + props.name, + props.datatype + ); + // Force re-render after appending generated AI text due to uncontrolled component + setKey(key + 1); + } else { + props.onChange( + { target: { value: `${props.value || ""}${generatedText}` } }, + props.name + ); + } + }; + + if (isEnabled) { + return ( + <> + + + + } + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + /> + + { + console.log("closing ai generator"); + handleClose("close"); + }} + slotProps={{ + paper: { + sx: { + overflowY: "hidden", + + "&:after": { + content: '""', + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + background: + "linear-gradient(0deg, rgba(255,93,10,1) 0%, rgba(18,183,106,1) 25%, rgba(11,165,236,1) 50%, rgba(238,70,188,1) 75%, rgba(105,56,239,1) 100%)", + animation: `${rotateAnimation} 1.5s linear alternate infinite`, + backgroundSize: "300% 300%", + }, + }, + }, + }} + > + handleClose(reason)} + aiType={props.aiType} + label={props.label} + isAIAssistedFlow={props.isAIAssistedFlow} + /> + + + ); - // Force re-render after appending generated AI text due to uncontrolled component - setKey(key + 1); } else { - props.onChange( - { target: { value: `${props.value}${generatedText}` } }, - props.name - ); + return ; } - }; - - if (isEnabled) { - return ( - <> - - - focused - ? "primary.main" - : `${theme.palette.action.active}`, - }, - "svg:hover": { - color: "primary.main", - }, - }} - onClick={(event: MouseEvent) => { - const target = event.target as HTMLElement; - if (target.nodeName === "svg" || target.nodeName === "path") { - handleClick(event); - } - }} - size="xxsmall" - > - - - - } - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - /> - - - - - - - ); - } else { - return ; - } -}; + });