| 1 |
import React, { useMemo, useState } from "react"; |
| 2 |
import { __ } from "@wordpress/i18n"; |
| 3 |
import { v4 } from "uuid"; |
| 4 |
import { useBuilderContext, withLabel } from "quickbuilder"; |
| 5 |
import ReactModal from "react-modal"; |
| 6 |
import Swal from "sweetalert2"; |
| 7 |
import CloseIcon from "../components/CloseIcon"; |
| 8 |
import { apiFetch, toastAlert } from "../../utils"; |
| 9 |
|
| 10 |
// ── Edit AI Personalize → Edit with AI actions ─────────────────────────────── |
| 11 |
// List + modal editor, mirroring the Write AI Personalize instruction sets. |
| 12 |
// Predefined actions (predefined === true) can be toggled on/off and have their |
| 13 |
// instruction edited, but their label is locked and they can't be deleted. Custom |
| 14 |
// actions can be added, fully edited, and removed. Enable toggles persist inline; |
| 15 |
// add/edit happens in a popup, persisted via the settings endpoint (merges per-key). |
| 16 |
|
| 17 |
// Predefined actions whose prompt carries a {option} placeholder, with the label |
| 18 |
// of the option BetterDocs substitutes at runtime (tone / language picker). |
| 19 |
const OPTION_HINTS = { |
| 20 |
change_tone: __("tone", "betterdocs"), |
| 21 |
translate: __("language", "betterdocs"), |
| 22 |
}; |
| 23 |
|
| 24 |
// Row action icons (match WwaInstructions.js / BetterRepeaterValueShow.js). |
| 25 |
const EditIcon = ({ onClick }) => ( |
| 26 |
<svg xmlns="http://www.w3.org/2000/svg" width={12} height={12} viewBox="0 0 12 12" fill="none" onClick={onClick}> |
| 27 |
<path fillRule="evenodd" clipRule="evenodd" d="M8.60581 0.582347C8.97868 0.209473 9.48441 0 10.0117 0C10.2729 0 10.5314 0.0514267 10.7726 0.151347C11.0139 0.251267 11.233 0.39772 11.4177 0.582347C11.6023 0.766973 11.7487 0.98616 11.8487 1.22739C11.9486 1.46861 12 1.72716 12 1.98826C12 2.24936 11.9486 2.50791 11.8487 2.74913C11.7487 2.99036 11.6023 3.20955 11.4177 3.39417L10.6821 4.12977C10.4217 4.39013 9.99961 4.39013 9.73928 4.12978L7.87021 2.26076C7.60988 2.00041 7.60988 1.5783 7.87021 1.31795L8.60581 0.582347ZM6.92741 3.20357C6.66708 2.94322 6.24495 2.94322 5.98461 3.20357L1.34057 7.8476C0.913375 8.2748 0.610308 8.81007 0.463781 9.3962L0.0199214 11.1717C-0.0368786 11.3988 0.0296881 11.6391 0.195275 11.8047C0.360861 11.9703 0.601188 12.0369 0.828375 11.9801L2.60382 11.5362C3.18993 11.3897 3.72519 11.0867 4.15239 10.6595L8.79641 6.0154C9.05681 5.75507 9.05681 5.33293 8.79641 5.0726L6.92741 3.20357Z" fill="#718096" /> |
| 28 |
<path fillRule="evenodd" clipRule="evenodd" d="M6 11.3346C6 10.9664 6.29847 10.668 6.66667 10.668H11.3333C11.7015 10.668 12 10.9664 12 11.3346C12 11.7028 11.7015 12.0013 11.3333 12.0013H6.66667C6.29847 12.0013 6 11.7028 6 11.3346Z" fill="#718096" /> |
| 29 |
</svg> |
| 30 |
); |
| 31 |
|
| 32 |
const DeleteIcon = ({ onClick }) => ( |
| 33 |
<svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} viewBox="0 0 16 16" fill="none" onClick={onClick}> |
| 34 |
<path d="M2 3.9987H3.33333M3.33333 3.9987H14M3.33333 3.9987V13.332C3.33333 13.6857 3.47381 14.0248 3.72386 14.2748C3.97391 14.5249 4.31304 14.6654 4.66667 14.6654H11.3333C11.687 14.6654 12.0261 14.5249 12.2761 14.2748C12.5262 14.0248 12.6667 13.6857 12.6667 13.332V3.9987H3.33333ZM5.33333 3.9987V2.66536C5.33333 2.31174 5.47381 1.9726 5.72386 1.72256C5.97391 1.47251 6.31304 1.33203 6.66667 1.33203H9.33333C9.68696 1.33203 10.0261 1.47251 10.2761 1.72256C10.5262 1.9726 10.6667 2.31174 10.6667 2.66536V3.9987M6.66667 7.33203V11.332M9.33333 7.33203V11.332" stroke="#98A2B3" strokeWidth={1.66667} strokeLinecap="round" strokeLinejoin="round" /> |
| 35 |
</svg> |
| 36 |
); |
| 37 |
|
| 38 |
// Lightweight enable/disable switch — self-contained inline styles so the field |
| 39 |
// doesn't depend on quickbuilder's toggle markup. |
| 40 |
const Toggle = ({ checked, onChange }) => ( |
| 41 |
<button |
| 42 |
type="button" |
| 43 |
role="switch" |
| 44 |
aria-checked={checked} |
| 45 |
onClick={() => onChange(!checked)} |
| 46 |
style={{ |
| 47 |
position: "relative", width: 36, height: 20, borderRadius: 999, |
| 48 |
border: "none", cursor: "pointer", padding: 0, flex: "0 0 auto", |
| 49 |
background: checked ? "#00b884" : "#cbd2d9", transition: "background .15s", |
| 50 |
}} |
| 51 |
> |
| 52 |
<span style={{ |
| 53 |
position: "absolute", top: 2, left: checked ? 18 : 2, width: 16, height: 16, |
| 54 |
borderRadius: "50%", background: "#fff", transition: "left .15s", |
| 55 |
boxShadow: "0 1px 2px rgba(0,0,0,0.2)", |
| 56 |
}} /> |
| 57 |
</button> |
| 58 |
); |
| 59 |
|
| 60 |
const formStyles = { |
| 61 |
label: { display: "block", fontSize: 13, fontWeight: 600, color: "#1e2329", margin: "0 0 6px" }, |
| 62 |
input: { |
| 63 |
width: "100%", boxSizing: "border-box", border: "1px solid #d8dde2", borderRadius: 7, |
| 64 |
padding: "10px 12px", fontSize: 14, color: "#1e2329", outline: "none", |
| 65 |
}, |
| 66 |
textarea: { |
| 67 |
width: "100%", boxSizing: "border-box", minHeight: 180, resize: "vertical", |
| 68 |
border: "1px solid #d8dde2", borderRadius: 8, padding: "11px 13px", fontSize: 13, lineHeight: 1.55, |
| 69 |
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace", |
| 70 |
color: "#1e2329", outline: "none", |
| 71 |
}, |
| 72 |
group: { marginBottom: 18 }, |
| 73 |
lockedNote: { fontSize: 12.5, color: "#667085", marginTop: 6 }, |
| 74 |
footer: { |
| 75 |
display: "flex", justifyContent: "flex-end", alignItems: "center", |
| 76 |
gap: 10, marginTop: 26, |
| 77 |
}, |
| 78 |
btnCancel: { |
| 79 |
background: "#fff", border: "1px solid #d0d5dd", color: "#344054", |
| 80 |
borderRadius: 8, padding: "10px 20px", fontSize: 14, fontWeight: 600, |
| 81 |
cursor: "pointer", lineHeight: 1.42, |
| 82 |
}, |
| 83 |
btnSave: { |
| 84 |
background: "#00b884", border: "none", color: "#fff", |
| 85 |
borderRadius: 8, padding: "10px 24px", fontSize: 14, fontWeight: 600, |
| 86 |
cursor: "pointer", lineHeight: 1.42, |
| 87 |
}, |
| 88 |
}; |
| 89 |
|
| 90 |
// react-modal overlay + content for the shared FAQ-builder modal shell |
| 91 |
// (.bd-faq-ctg-modal), matching CategoryModal so the modal reads identically. |
| 92 |
const customStyles = { |
| 93 |
overlay: { |
| 94 |
position: "fixed", top: 0, left: 0, right: 0, bottom: 0, |
| 95 |
backgroundColor: "rgba(35, 40, 45, 0.5)", zIndex: 99999, |
| 96 |
padding: 50, paddingTop: 70, display: "flex", flexDirection: "column", overflow: "auto", |
| 97 |
}, |
| 98 |
content: { |
| 99 |
width: "40rem", maxWidth: "90%", position: "static", height: "auto", overflow: "initial", margin: "auto", |
| 100 |
}, |
| 101 |
}; |
| 102 |
|
| 103 |
const noteStyle = { margin: "6px 0 0", fontSize: 12.5, lineHeight: 1.45, color: "var(--text-color-600, #667085)" }; |
| 104 |
|
| 105 |
const EditAIActions = (props) => { |
| 106 |
const builderContext = useBuilderContext(); |
| 107 |
const fieldName = props.name; |
| 108 |
|
| 109 |
const raw = builderContext.values?.[fieldName]; |
| 110 |
const items = useMemo(() => { |
| 111 |
const source = Array.isArray(raw) ? raw : Array.isArray(props.value) ? props.value : []; |
| 112 |
return source.map((it) => ({ |
| 113 |
id: it?.id || v4(), |
| 114 |
label: it?.label ?? "", |
| 115 |
instruction: it?.instruction ?? "", |
| 116 |
enabled: it?.enabled === undefined ? true : !!it.enabled, |
| 117 |
predefined: !!it?.predefined, |
| 118 |
})); |
| 119 |
}, [raw, props.value]); |
| 120 |
|
| 121 |
// editing: { index, isNew, draft } | null |
| 122 |
const [editing, setEditing] = useState(null); |
| 123 |
|
| 124 |
// Update form state for immediate UI feedback, then persist this single field |
| 125 |
// via the settings endpoint (it merges per-key) — no page-level Save needed. |
| 126 |
const persist = (next) => { |
| 127 |
builderContext.setFieldValue(fieldName, next); |
| 128 |
apiFetch |
| 129 |
.post("settings", { [fieldName]: next, _active_tab: "tab-betterdocs-ai" }) |
| 130 |
.then((res) => { |
| 131 |
if (res?.success) { |
| 132 |
toastAlert("success", __("Actions saved.", "betterdocs")); |
| 133 |
} else { |
| 134 |
toastAlert( |
| 135 |
res?.code === "nothing_changed" ? "info" : "error", |
| 136 |
__(res?.message ?? "Couldn't save actions.", "betterdocs") |
| 137 |
); |
| 138 |
} |
| 139 |
}); |
| 140 |
}; |
| 141 |
|
| 142 |
const toast = (type, msg) => { |
| 143 |
if (props?.context?.alerts?.toast) props.context.alerts.toast(type, msg); |
| 144 |
else toastAlert(type, msg); |
| 145 |
}; |
| 146 |
|
| 147 |
const onToggle = (index, enabled) => { |
| 148 |
persist(items.map((it, i) => (i === index ? { ...it, enabled } : it))); |
| 149 |
}; |
| 150 |
|
| 151 |
const openEdit = (index) => { |
| 152 |
setEditing({ index, isNew: false, draft: { ...items[index] } }); |
| 153 |
}; |
| 154 |
|
| 155 |
const openAdd = () => { |
| 156 |
setEditing({ |
| 157 |
index: -1, |
| 158 |
isNew: true, |
| 159 |
draft: { id: v4(), label: "", instruction: "", enabled: true, predefined: false }, |
| 160 |
}); |
| 161 |
}; |
| 162 |
|
| 163 |
const closeModal = () => setEditing(null); |
| 164 |
|
| 165 |
const saveModal = () => { |
| 166 |
if (!editing) return; |
| 167 |
const draft = { |
| 168 |
id: editing.draft.id || v4(), |
| 169 |
label: (editing.draft.label || "").trim(), |
| 170 |
instruction: editing.draft.instruction || "", |
| 171 |
enabled: editing.draft.enabled === undefined ? true : !!editing.draft.enabled, |
| 172 |
predefined: !!editing.draft.predefined, |
| 173 |
}; |
| 174 |
if (!draft.predefined && draft.label === "") { |
| 175 |
toast("error", __("Please add a name for this action.", "betterdocs")); |
| 176 |
return; |
| 177 |
} |
| 178 |
if (draft.instruction.trim() === "") { |
| 179 |
toast("error", __("Instruction can't be empty.", "betterdocs")); |
| 180 |
return; |
| 181 |
} |
| 182 |
|
| 183 |
if (editing.isNew) { |
| 184 |
persist([...items, draft]); |
| 185 |
} else { |
| 186 |
persist(items.map((it, i) => (i === editing.index ? draft : it))); |
| 187 |
} |
| 188 |
setEditing(null); |
| 189 |
}; |
| 190 |
|
| 191 |
const onDelete = (index) => { |
| 192 |
if (items[index]?.predefined) return; |
| 193 |
Swal.fire({ |
| 194 |
text: __("Are you sure you want to delete this action?", "betterdocs"), |
| 195 |
icon: "warning", |
| 196 |
showConfirmButton: true, |
| 197 |
showCancelButton: true, |
| 198 |
confirmButtonText: __("Yes", "betterdocs"), |
| 199 |
cancelButtonText: __("No", "betterdocs"), |
| 200 |
customClass: { popup: "betterdocs-bulk-action-popup" }, |
| 201 |
}).then((result) => { |
| 202 |
if (result?.isConfirmed) { |
| 203 |
persist(items.filter((_, i) => i !== index)); |
| 204 |
} |
| 205 |
}); |
| 206 |
}; |
| 207 |
|
| 208 |
const setDraft = (patch) => |
| 209 |
setEditing((e) => (e ? { ...e, draft: { ...e.draft, ...patch } } : e)); |
| 210 |
|
| 211 |
const editingIsPredefined = editing && editing.draft.predefined; |
| 212 |
const editingOptionHint = editing && OPTION_HINTS[editing.draft.id]; |
| 213 |
|
| 214 |
return ( |
| 215 |
<div className="wprf-control-field wwa-instructions-field edit-ai-actions-field"> |
| 216 |
<div className="wprf-repeater-control"> |
| 217 |
<div className="wprf-repeater-content"> |
| 218 |
{items.map((item, index) => ( |
| 219 |
<div className="wprf-repeater-field" key={item.id}> |
| 220 |
<div className="wprf-repeater-inner-field betterdocs"> |
| 221 |
<div className="wprf-repeater-inner-field-item"> |
| 222 |
<p>{item.label || __("(Untitled)", "betterdocs")}</p> |
| 223 |
</div> |
| 224 |
<div className="betterdocs-action-toggle-wrapper"> |
| 225 |
<div className="betterdocs-cookies-list-action"> |
| 226 |
<ul id="betterdocs-admin-actions-ul" style={{ display: "flex", alignItems: "center", gap: 12 }}> |
| 227 |
<Toggle checked={item.enabled} onChange={(v) => onToggle(index, v)} /> |
| 228 |
<EditIcon onClick={() => openEdit(index)} /> |
| 229 |
{!item.predefined && ( |
| 230 |
<DeleteIcon onClick={() => onDelete(index)} /> |
| 231 |
)} |
| 232 |
</ul> |
| 233 |
</div> |
| 234 |
</div> |
| 235 |
</div> |
| 236 |
</div> |
| 237 |
))} |
| 238 |
</div> |
| 239 |
|
| 240 |
<div className="wprf-repeater-label" onClick={openAdd}> |
| 241 |
<button type="button" className="wprf-repeater-button" onClick={openAdd}> |
| 242 |
+ {__("Add Action", "betterdocs")} |
| 243 |
</button> |
| 244 |
</div> |
| 245 |
</div> |
| 246 |
|
| 247 |
<ReactModal |
| 248 |
isOpen={!!editing} |
| 249 |
ariaHideApp={false} |
| 250 |
className="bd-faq-ctg-modal" |
| 251 |
onRequestClose={closeModal} |
| 252 |
style={customStyles} |
| 253 |
> |
| 254 |
<div className="bd-faq-ctg-header"> |
| 255 |
<span |
| 256 |
className="bd-faq-stat-card__icon" |
| 257 |
style={{ "--bd-icon-tint": "#7f56d9", "--bd-icon-tint-rgb": "127, 86, 217" }} |
| 258 |
> |
| 259 |
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round"> |
| 260 |
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" /> |
| 261 |
<path d="M19 15l.9 2.4L22 18l-2.1.6L19 21l-.9-2.4L16 18l2.1-.6z" /> |
| 262 |
</svg> |
| 263 |
</span> |
| 264 |
<div className="bd-faq-ctg-heading"> |
| 265 |
<h3 className="bd-faq-ctg-title"> |
| 266 |
{editing && editing.isNew ? __("Add Action", "betterdocs") : __("Edit Action", "betterdocs")} |
| 267 |
</h3> |
| 268 |
<p className="bd-faq-ctg-subtitle"> |
| 269 |
{__("Name the action and write the instruction BetterDocs AI should follow when it runs on the selected content.", "betterdocs")} |
| 270 |
</p> |
| 271 |
</div> |
| 272 |
</div> |
| 273 |
|
| 274 |
{editing && ( |
| 275 |
<div className="bd-faq-ctg-body"> |
| 276 |
<div className="bd-faq-cgt-field"> |
| 277 |
<label className="bd-faq-label">{__("Action name", "betterdocs")}</label> |
| 278 |
<input |
| 279 |
type="text" |
| 280 |
className="bd-faq-input" |
| 281 |
style={editingIsPredefined ? { background: "var(--text-color-100)", color: "var(--text-color-600)", cursor: "not-allowed" } : undefined} |
| 282 |
value={editing.draft.label} |
| 283 |
readOnly={editingIsPredefined} |
| 284 |
placeholder={__("e.g. Add a TL;DR", "betterdocs")} |
| 285 |
onChange={(e) => setDraft({ label: e.target.value })} |
| 286 |
/> |
| 287 |
{editingIsPredefined && ( |
| 288 |
<p style={noteStyle}> |
| 289 |
{__("This is a built-in action — its name is locked, but you can edit its instruction or turn it off.", "betterdocs")} |
| 290 |
</p> |
| 291 |
)} |
| 292 |
</div> |
| 293 |
|
| 294 |
<div className="bd-faq-cgt-field"> |
| 295 |
<label className="bd-faq-label">{__("Instruction", "betterdocs")}</label> |
| 296 |
<textarea |
| 297 |
className="bd-faq-textarea" |
| 298 |
rows={5} |
| 299 |
value={editing.draft.instruction} |
| 300 |
placeholder={__("Describe exactly what the AI should do with the selected content…", "betterdocs")} |
| 301 |
onChange={(e) => setDraft({ instruction: e.target.value })} |
| 302 |
/> |
| 303 |
{editingOptionHint && ( |
| 304 |
<p style={noteStyle}>{sprintfOption(editingOptionHint)}</p> |
| 305 |
)} |
| 306 |
</div> |
| 307 |
</div> |
| 308 |
)} |
| 309 |
|
| 310 |
<div className="bd-faq-ctg-footer"> |
| 311 |
<button type="button" className="bd-faq-button button-ghost" onClick={closeModal}> |
| 312 |
<span className="bd-faq-text">{__("Cancel", "betterdocs")}</span> |
| 313 |
</button> |
| 314 |
<button type="button" className="bd-faq-button button-success" onClick={saveModal}> |
| 315 |
<span className="bd-faq-text">{editing && editing.isNew ? __("Save", "betterdocs") : __("Update", "betterdocs")}</span> |
| 316 |
</button> |
| 317 |
</div> |
| 318 |
|
| 319 |
<button type="button" className="bd-faq-modal-close-button" onClick={closeModal}> |
| 320 |
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 321 |
<path d="M1.69687 14L0.296875 12.6L5.89687 7L0.296875 1.4L1.69687 0L7.29688 5.6L12.8969 0L14.2969 1.4L8.69688 7L14.2969 12.6L12.8969 14L7.29688 8.4L1.69687 14Z" fill="currentColor" /> |
| 322 |
</svg> |
| 323 |
</button> |
| 324 |
</ReactModal> |
| 325 |
</div> |
| 326 |
); |
| 327 |
}; |
| 328 |
|
| 329 |
// Keep the {option} placeholder explanation readable without string concatenation |
| 330 |
// inside JSX. `which` is "tone" or "language". |
| 331 |
const sprintfOption = (which) => |
| 332 |
__("Keep the ", "betterdocs") + |
| 333 |
"{option}" + |
| 334 |
__(" placeholder where the selected ", "betterdocs") + |
| 335 |
which + |
| 336 |
__(" should appear.", "betterdocs"); |
| 337 |
|
| 338 |
export default withLabel(React.memo(EditAIActions)); |
| 339 |
|