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/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
+
+ }
+ sx={{ mt: 4 }}
+ onClick={() => request.current?.abort()}
+ >
+ Stop
+
+
+
+ );
+ }
+
+ // 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
-
- }
- sx={{ mt: 3 }}
- onClick={() => request.current?.abort()}
+
- Stop
-
-
+
+ {!!data?.length ? (
+
+ }
+ onClick={() => setData(null)}
+ >
+ Regenerate
+
+
+
+ ) : (
+
+ )}
+
+
);
}
+ // 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,
+ },
+ },
+ }}
/>
-
- >
+
+
)}
-
-
)}
-
+
);
};
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 (
+ <>
+
+ }
+ variant="text"
+ color="inherit"
+ onClick={handleClick}
+ ref={aiButtonRef}
+ sx={{
+ backgroundColor: (theme) =>
+ Boolean(anchorEl)
+ ? alpha(theme.palette.primary.main, 0.08)
+ : "transparent",
+ minWidth: 0,
+ fontWeight: 600,
+ fontSize: 14,
+ lineHeight: "14px",
+ px: 0.5,
+ py: 0.25,
+ color: Boolean(anchorEl) ? "primary.main" : "text.disabled",
+
+ "&:hover": {
+ backgroundColor: (theme) =>
+ alpha(theme.palette.primary.main, 0.08),
+ color: "primary.main",
+ },
+
+ "&:hover .MuiButton-endIcon .MuiSvgIcon-root": {
+ fill: (theme) => theme.palette.primary.main,
+ },
+
+ "& .MuiButton-endIcon": {
+ ml: 0.5,
+ mr: 0,
+ },
+
+ "& .MuiButton-endIcon .MuiSvgIcon-root": {
+ fontSize: 16,
+ fill: (theme) =>
+ Boolean(anchorEl)
+ ? theme.palette.primary.main
+ : theme.palette.action.active,
+ },
+ }}
+ >
+ AI
+
+
+ }
+ 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 ;
- }
-};
+ });