Implement task 006 site config versioning and audit events
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test:roles": "node tests/role-navigation.test.mjs",
|
||||
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs",
|
||||
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import AdminScriptsPage from "@/pages/admin-scripts";
|
||||
|
||||
export { AdminScriptsPage as default };
|
||||
@@ -0,0 +1,3 @@
|
||||
import AdminSitesPage from "@/pages/admin-sites";
|
||||
|
||||
export default AdminSitesPage;
|
||||
@@ -149,18 +149,68 @@ main {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.codeArea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
min-height: 120px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
margin: 0;
|
||||
color: #b42318;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.formSuccess {
|
||||
margin: 0;
|
||||
color: #027a48;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 4px 0 0;
|
||||
color: #b42318;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inlineLinks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.inlineLinks a {
|
||||
text-decoration: none;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background: #ffffff;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.inlineLinks .activeLink {
|
||||
border-color: #1f4b99;
|
||||
color: #1f4b99;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table button {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1d2733;
|
||||
background: #ffffff;
|
||||
color: #1d2733;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.tableAction {
|
||||
background: #eceff5 !important;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionSummary,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export type AdminScriptVersionRow = {
|
||||
id: string;
|
||||
version: number;
|
||||
status: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
activatedAt: string | null;
|
||||
publishingYamlHash: string;
|
||||
transformScriptHash: string;
|
||||
isActive: boolean;
|
||||
rollbackTargetVersionId: string | null;
|
||||
diff: string;
|
||||
};
|
||||
|
||||
export type NewAdminScriptVersionDraft = {
|
||||
publishingYaml: string;
|
||||
transformScript: string;
|
||||
diff: string;
|
||||
activate: boolean;
|
||||
rollbackTargetVersionId: string;
|
||||
};
|
||||
|
||||
export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>;
|
||||
|
||||
const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}';
|
||||
const DEFAULT_PUBLISHING_YAML = "target: site\nrepository:\n branch: main\n";
|
||||
const DEFAULT_TRANSFORM_SCRIPT = "export function transformArticle(article) {\n return article;\n}\n";
|
||||
|
||||
export function buildAdminScriptVersionRows(
|
||||
versions: readonly ScriptConfigVersionSummary[],
|
||||
): AdminScriptVersionRow[] {
|
||||
return versions.map((version) => ({
|
||||
id: version.id,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
createdBy: version.created_by,
|
||||
createdAt: version.created_at,
|
||||
activatedAt: version.activated_at ?? null,
|
||||
publishingYamlHash: version.publishing_yaml_hash,
|
||||
transformScriptHash: version.transform_script_hash,
|
||||
isActive: version.status === "ACTIVE",
|
||||
rollbackTargetVersionId: version.rollback_target_version_id ?? null,
|
||||
diff: JSON.stringify(version.diff),
|
||||
}));
|
||||
}
|
||||
|
||||
export function defaultScriptVersionDraft(): NewAdminScriptVersionDraft {
|
||||
return {
|
||||
publishingYaml: DEFAULT_PUBLISHING_YAML,
|
||||
transformScript: DEFAULT_TRANSFORM_SCRIPT,
|
||||
diff: DEFAULT_DIFF,
|
||||
activate: false,
|
||||
rollbackTargetVersionId: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateScriptVersionPayload(
|
||||
draft: NewAdminScriptVersionDraft,
|
||||
): ScriptConfigVersionCreateRequest {
|
||||
const parsedDiff = parseJsonObject(draft.diff) ?? {};
|
||||
|
||||
return {
|
||||
publishing_yaml: draft.publishingYaml,
|
||||
transform_script: draft.transformScript,
|
||||
diff: parsedDiff,
|
||||
activate: draft.activate,
|
||||
rollback_target_version_id: draft.rollbackTargetVersionId
|
||||
? (draft.rollbackTargetVersionId as string)
|
||||
: null,
|
||||
version: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJsonObject(value: string): Record<string, unknown> | null {
|
||||
if (!value.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
parsed !== null
|
||||
) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
|
||||
import {
|
||||
buildAdminScriptVersionRows,
|
||||
buildCreateScriptVersionPayload,
|
||||
defaultScriptVersionDraft,
|
||||
parseJsonObject,
|
||||
type DraftValidationErrors,
|
||||
type NewAdminScriptVersionDraft,
|
||||
} from "./model";
|
||||
|
||||
type AdminScriptVersionsPanelProps = {
|
||||
site: TargetSiteConfig;
|
||||
initialVersions: readonly ScriptConfigVersionSummary[];
|
||||
};
|
||||
|
||||
function parseOptionalError(value: string): string | null {
|
||||
if (value.trim().length === 0) {
|
||||
return "Required value";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AdminScriptVersionsPanel({
|
||||
site,
|
||||
initialVersions,
|
||||
}: AdminScriptVersionsPanelProps) {
|
||||
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
|
||||
initialVersions,
|
||||
);
|
||||
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
|
||||
defaultScriptVersionDraft(),
|
||||
);
|
||||
const [errors, setErrors] = useState<DraftValidationErrors>({});
|
||||
const [formError, setFormError] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
buildAdminScriptVersionRows(versions),
|
||||
[versions],
|
||||
);
|
||||
|
||||
const hasVersions = rows.length > 0;
|
||||
const canSubmit = !isSubmitting;
|
||||
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
|
||||
|
||||
async function refreshVersions() {
|
||||
const latest = await fetchScriptConfigVersions(
|
||||
site.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
setVersions(latest.versions);
|
||||
}
|
||||
|
||||
function updateField<K extends keyof NewAdminScriptVersionDraft>(
|
||||
field: K,
|
||||
value: NewAdminScriptVersionDraft[K],
|
||||
) {
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
setFormError("");
|
||||
setActionMessage("");
|
||||
}
|
||||
|
||||
async function handleCreateVersion(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const nextErrors: DraftValidationErrors = {};
|
||||
|
||||
const publishingYamlError = parseOptionalError(draft.publishingYaml);
|
||||
const transformScriptError = parseOptionalError(draft.transformScript);
|
||||
const diffObject = parseJsonObject(draft.diff);
|
||||
|
||||
if (publishingYamlError) {
|
||||
nextErrors.publishingYaml = publishingYamlError;
|
||||
}
|
||||
if (transformScriptError) {
|
||||
nextErrors.transformScript = transformScriptError;
|
||||
}
|
||||
if (diffObject === null) {
|
||||
nextErrors.diff = "Invalid JSON object";
|
||||
}
|
||||
|
||||
if (draft.rollbackTargetVersionId) {
|
||||
const validRollback = rows.some((row) => row.id === draft.rollbackTargetVersionId);
|
||||
if (!validRollback) {
|
||||
nextErrors.rollbackTargetVersionId = "Select a valid version ID";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
clearMessages();
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const payload = buildCreateScriptVersionPayload(draft);
|
||||
await createScriptConfigVersion(site.id, payload, DEMO_ADMIN_EMAIL);
|
||||
setActionMessage("New version created");
|
||||
await refreshVersions();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message || "Request failed");
|
||||
} else {
|
||||
setFormError("Unable to create publishing version");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate(versionId: string) {
|
||||
setSubmitting(true);
|
||||
clearMessages();
|
||||
try {
|
||||
await activateScriptConfigVersion(site.id, versionId, DEMO_ADMIN_EMAIL);
|
||||
await refreshVersions();
|
||||
setActionMessage("Version activated");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message || "Request failed");
|
||||
} else {
|
||||
setFormError("Unable to activate version");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback(versionId: string) {
|
||||
setSubmitting(true);
|
||||
clearMessages();
|
||||
try {
|
||||
await rollbackScriptConfigVersion(site.id, versionId, DEMO_ADMIN_EMAIL);
|
||||
await refreshVersions();
|
||||
setActionMessage("Rollback applied");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message || "Request failed");
|
||||
} else {
|
||||
setFormError("Unable to rollback version");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>{site.name} script versions</h2>
|
||||
{formError ? <p className="formError">Error: {formError}</p> : null}
|
||||
{actionMessage ? <p className="formSuccess">{actionMessage}</p> : null}
|
||||
|
||||
<h3>Create new version</h3>
|
||||
<form className="articleForm" onSubmit={handleCreateVersion}>
|
||||
<label htmlFor="publishingYaml">Publishing YAML</label>
|
||||
<textarea
|
||||
id="publishingYaml"
|
||||
className="codeArea"
|
||||
value={draft.publishingYaml}
|
||||
onChange={(event) => updateField("publishingYaml", event.target.value)}
|
||||
/>
|
||||
{errors.publishingYaml ? <p className="fieldError">{errors.publishingYaml}</p> : null}
|
||||
|
||||
<label htmlFor="transformScript">Transform script</label>
|
||||
<textarea
|
||||
id="transformScript"
|
||||
className="codeArea"
|
||||
value={draft.transformScript}
|
||||
onChange={(event) => updateField("transformScript", event.target.value)}
|
||||
/>
|
||||
{errors.transformScript ? (
|
||||
<p className="fieldError">{errors.transformScript}</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="diff">Diff payload (JSON)</label>
|
||||
<textarea
|
||||
id="diff"
|
||||
className="codeArea"
|
||||
value={draft.diff}
|
||||
onChange={(event) => updateField("diff", event.target.value)}
|
||||
/>
|
||||
{errors.diff ? <p className="fieldError">{errors.diff}</p> : null}
|
||||
|
||||
<label htmlFor="activate">
|
||||
<input
|
||||
id="activate"
|
||||
type="checkbox"
|
||||
checked={draft.activate}
|
||||
onChange={(event) => updateField("activate", event.target.checked)}
|
||||
/>
|
||||
Activate on create
|
||||
</label>
|
||||
|
||||
<label htmlFor="rollbackTargetVersionId">Rollback target version (optional)</label>
|
||||
<select
|
||||
id="rollbackTargetVersionId"
|
||||
value={draft.rollbackTargetVersionId}
|
||||
onChange={(event) => updateField("rollbackTargetVersionId", event.target.value)}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{rollbackCandidates.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
v{candidate.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.rollbackTargetVersionId ? (
|
||||
<p className="fieldError">{errors.rollbackTargetVersionId}</p>
|
||||
) : null}
|
||||
|
||||
<div className="formActions">
|
||||
<button type="submit" disabled={!canSubmit}>
|
||||
{isSubmitting ? "Saving..." : "Create version"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Version history</h3>
|
||||
{!hasVersions ? (
|
||||
<p className="emptyState">No versions yet.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Activated</th>
|
||||
<th>Author</th>
|
||||
<th>Hashes</th>
|
||||
<th>Rollback target</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((version) => (
|
||||
<tr key={version.id}>
|
||||
<td>
|
||||
{version.version} {version.isActive ? "(active)" : null}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<strong>diff</strong>: {version.diff}
|
||||
</div>
|
||||
</td>
|
||||
<td>{version.status}</td>
|
||||
<td>{new Date(version.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
{version.activatedAt
|
||||
? new Date(version.activatedAt).toLocaleString()
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{version.createdBy}</td>
|
||||
<td>
|
||||
<div>YAML: {version.publishingYamlHash.slice(0, 8)}</div>
|
||||
<div>Script: {version.transformScriptHash.slice(0, 8)}</div>
|
||||
</td>
|
||||
<td>{version.rollbackTargetVersionId ?? "—"}</td>
|
||||
<td>
|
||||
{!version.isActive ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleActivate(version.id)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRollback(version.id)}
|
||||
disabled={isSubmitting}
|
||||
className="tableAction"
|
||||
>
|
||||
Rollback
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>Current</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { TargetSiteConfigCreateRequest } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, createTargetSite, DEMO_ADMIN_EMAIL } from "@/shared/pipeline-api";
|
||||
|
||||
type AdminSiteDraft = {
|
||||
name: string;
|
||||
slug: string;
|
||||
defaultLanguage: string;
|
||||
brandVoice: string;
|
||||
audience: string;
|
||||
publishingType: string;
|
||||
repositoryUrl: string;
|
||||
productionBranch: string;
|
||||
contentFormat: string;
|
||||
contentPathTemplate: string;
|
||||
assetPathTemplate: string;
|
||||
dryRunRenderer: string;
|
||||
frontmatterMapping: string;
|
||||
seoRules: string;
|
||||
visualRules: string;
|
||||
sourceRules: string;
|
||||
};
|
||||
|
||||
type ValidationErrors = Partial<Record<keyof AdminSiteDraft, string>>;
|
||||
|
||||
const defaultDraft = (): AdminSiteDraft => ({
|
||||
name: "",
|
||||
slug: "",
|
||||
defaultLanguage: "en",
|
||||
brandVoice: "Practical and helpful editorial guidance.",
|
||||
audience: "Content and marketing teams.",
|
||||
publishingType: "git_next",
|
||||
repositoryUrl: "",
|
||||
productionBranch: "main",
|
||||
contentFormat: "mdx",
|
||||
contentPathTemplate: "content/articles/{slug}.mdx",
|
||||
assetPathTemplate: "public/articles/{slug}/{filename}",
|
||||
dryRunRenderer: "next-mdx",
|
||||
frontmatterMapping: JSON.stringify(
|
||||
{
|
||||
title: "title",
|
||||
description: "description",
|
||||
publishedAt: "published_at",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
seoRules: JSON.stringify({}, null, 2),
|
||||
visualRules: JSON.stringify({}, null, 2),
|
||||
sourceRules: JSON.stringify({}, null, 2),
|
||||
});
|
||||
|
||||
function parseJsonField(value: string): Record<string, unknown> | null {
|
||||
if (!value.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AdminSiteCreateForm() {
|
||||
const router = useRouter();
|
||||
const [values, setValues] = useState<AdminSiteDraft>(defaultDraft());
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [formError, setFormError] = useState("");
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() =>
|
||||
!isSubmitting &&
|
||||
values.name.trim() !== "" &&
|
||||
values.slug.trim() !== "" &&
|
||||
values.repositoryUrl.trim() !== "" &&
|
||||
values.brandVoice.trim() !== "" &&
|
||||
values.audience.trim() !== "",
|
||||
[isSubmitting, values],
|
||||
);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const nextErrors: ValidationErrors = {};
|
||||
const parsedFrontmatter = parseJsonField(values.frontmatterMapping);
|
||||
const parsedSeo = parseJsonField(values.seoRules);
|
||||
const parsedVisual = parseJsonField(values.visualRules);
|
||||
const parsedSource = parseJsonField(values.sourceRules);
|
||||
|
||||
if (parsedFrontmatter === null) {
|
||||
nextErrors.frontmatterMapping = "Invalid JSON object";
|
||||
}
|
||||
if (parsedSeo === null) {
|
||||
nextErrors.seoRules = "Invalid JSON object";
|
||||
}
|
||||
if (parsedVisual === null) {
|
||||
nextErrors.visualRules = "Invalid JSON object";
|
||||
}
|
||||
if (parsedSource === null) {
|
||||
nextErrors.sourceRules = "Invalid JSON object";
|
||||
}
|
||||
if (!values.slug.trim()) {
|
||||
nextErrors.slug = "Slug is required";
|
||||
}
|
||||
if (!values.name.trim()) {
|
||||
nextErrors.name = "Name is required";
|
||||
}
|
||||
if (!values.repositoryUrl.trim()) {
|
||||
nextErrors.repositoryUrl = "Repository URL is required";
|
||||
}
|
||||
if (!values.brandVoice.trim()) {
|
||||
nextErrors.brandVoice = "Brand voice is required";
|
||||
}
|
||||
if (!values.audience.trim()) {
|
||||
nextErrors.audience = "Audience is required";
|
||||
}
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
setFormError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: TargetSiteConfigCreateRequest = {
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
publishing_type: values.publishingType,
|
||||
default_language: values.defaultLanguage,
|
||||
brand_voice: values.brandVoice,
|
||||
audience: values.audience,
|
||||
seo_rules: parsedSeo ?? {},
|
||||
visual_rules: parsedVisual ?? {},
|
||||
source_rules: parsedSource ?? {},
|
||||
publishing_rules: {
|
||||
repository_url: values.repositoryUrl,
|
||||
production_branch: values.productionBranch,
|
||||
content_format: values.contentFormat,
|
||||
content_path_template: values.contentPathTemplate,
|
||||
asset_path_template: values.assetPathTemplate,
|
||||
frontmatter_mapping: parsedFrontmatter ?? {},
|
||||
dry_run_renderer: values.dryRunRenderer || null,
|
||||
},
|
||||
};
|
||||
|
||||
await createTargetSite(payload, DEMO_ADMIN_EMAIL);
|
||||
router.push("/admin/sites");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message || "Request failed");
|
||||
} else {
|
||||
setFormError("Unable to create site config");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateField<K extends keyof AdminSiteDraft>(
|
||||
field: K,
|
||||
value: AdminSiteDraft[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="articleForm" onSubmit={handleSubmit}>
|
||||
<h2>Create target site config</h2>
|
||||
{formError ? <p className="formError">Error: {formError}</p> : null}
|
||||
<label htmlFor="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
value={values.name}
|
||||
onChange={(event) => updateField("name", event.target.value)}
|
||||
placeholder="Editorial blog"
|
||||
/>
|
||||
{errors.name ? <p className="fieldError">{errors.name}</p> : null}
|
||||
|
||||
<label htmlFor="slug">Slug</label>
|
||||
<input
|
||||
id="slug"
|
||||
value={values.slug}
|
||||
onChange={(event) => updateField("slug", event.target.value)}
|
||||
placeholder="editorial-blog"
|
||||
/>
|
||||
{errors.slug ? <p className="fieldError">{errors.slug}</p> : null}
|
||||
|
||||
<label htmlFor="defaultLanguage">Default language</label>
|
||||
<input
|
||||
id="defaultLanguage"
|
||||
value={values.defaultLanguage}
|
||||
onChange={(event) => updateField("defaultLanguage", event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="publishingType">Publishing type</label>
|
||||
<input
|
||||
id="publishingType"
|
||||
value={values.publishingType}
|
||||
onChange={(event) => updateField("publishingType", event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="brandVoice">Brand voice</label>
|
||||
<input
|
||||
id="brandVoice"
|
||||
value={values.brandVoice}
|
||||
onChange={(event) => updateField("brandVoice", event.target.value)}
|
||||
placeholder="Confident and practical"
|
||||
/>
|
||||
{errors.brandVoice ? <p className="fieldError">{errors.brandVoice}</p> : null}
|
||||
|
||||
<label htmlFor="audience">Audience</label>
|
||||
<input
|
||||
id="audience"
|
||||
value={values.audience}
|
||||
onChange={(event) => updateField("audience", event.target.value)}
|
||||
placeholder="Marketing and editorial teams"
|
||||
/>
|
||||
{errors.audience ? <p className="fieldError">{errors.audience}</p> : null}
|
||||
|
||||
<label htmlFor="repositoryUrl">Repository URL</label>
|
||||
<input
|
||||
id="repositoryUrl"
|
||||
value={values.repositoryUrl}
|
||||
onChange={(event) => updateField("repositoryUrl", event.target.value)}
|
||||
placeholder="git@github.com:org/repo.git"
|
||||
/>
|
||||
{errors.repositoryUrl ? <p className="fieldError">{errors.repositoryUrl}</p> : null}
|
||||
|
||||
<label htmlFor="productionBranch">Production branch</label>
|
||||
<input
|
||||
id="productionBranch"
|
||||
value={values.productionBranch}
|
||||
onChange={(event) => updateField("productionBranch", event.target.value)}
|
||||
placeholder="main"
|
||||
/>
|
||||
|
||||
<label htmlFor="contentFormat">Content format</label>
|
||||
<input
|
||||
id="contentFormat"
|
||||
value={values.contentFormat}
|
||||
onChange={(event) => updateField("contentFormat", event.target.value)}
|
||||
placeholder="mdx"
|
||||
/>
|
||||
|
||||
<label htmlFor="contentPathTemplate">Content path template</label>
|
||||
<input
|
||||
id="contentPathTemplate"
|
||||
value={values.contentPathTemplate}
|
||||
onChange={(event) => updateField("contentPathTemplate", event.target.value)}
|
||||
placeholder="content/articles/{slug}.mdx"
|
||||
/>
|
||||
|
||||
<label htmlFor="assetPathTemplate">Asset path template</label>
|
||||
<input
|
||||
id="assetPathTemplate"
|
||||
value={values.assetPathTemplate}
|
||||
onChange={(event) => updateField("assetPathTemplate", event.target.value)}
|
||||
placeholder="public/articles/{slug}/{filename}"
|
||||
/>
|
||||
|
||||
<label htmlFor="dryRunRenderer">Dry-run renderer</label>
|
||||
<input
|
||||
id="dryRunRenderer"
|
||||
value={values.dryRunRenderer}
|
||||
onChange={(event) => updateField("dryRunRenderer", event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="frontmatterMapping">Frontmatter mapping (JSON)</label>
|
||||
<textarea
|
||||
id="frontmatterMapping"
|
||||
className="codeArea"
|
||||
value={values.frontmatterMapping}
|
||||
onChange={(event) => updateField("frontmatterMapping", event.target.value)}
|
||||
/>
|
||||
{errors.frontmatterMapping ? (
|
||||
<p className="fieldError">{errors.frontmatterMapping}</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="seoRules">SEO rules (JSON)</label>
|
||||
<textarea
|
||||
id="seoRules"
|
||||
className="codeArea"
|
||||
value={values.seoRules}
|
||||
onChange={(event) => updateField("seoRules", event.target.value)}
|
||||
/>
|
||||
{errors.seoRules ? <p className="fieldError">{errors.seoRules}</p> : null}
|
||||
|
||||
<label htmlFor="visualRules">Visual rules (JSON)</label>
|
||||
<textarea
|
||||
id="visualRules"
|
||||
className="codeArea"
|
||||
value={values.visualRules}
|
||||
onChange={(event) => updateField("visualRules", event.target.value)}
|
||||
/>
|
||||
{errors.visualRules ? <p className="fieldError">{errors.visualRules}</p> : null}
|
||||
|
||||
<label htmlFor="sourceRules">Source rules (JSON)</label>
|
||||
<textarea
|
||||
id="sourceRules"
|
||||
className="codeArea"
|
||||
value={values.sourceRules}
|
||||
onChange={(event) => updateField("sourceRules", event.target.value)}
|
||||
/>
|
||||
{errors.sourceRules ? <p className="fieldError">{errors.sourceRules}</p> : null}
|
||||
|
||||
<div className="formActions">
|
||||
<button type="submit" disabled={!canSubmit}>
|
||||
{isSubmitting ? "Creating..." : "Create site"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { TargetSiteConfig } from "@pipeline/shared";
|
||||
|
||||
export type AdminSiteRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
publishingRepository: string;
|
||||
productionBranch: string;
|
||||
activeVersionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function buildAdminSiteRows(
|
||||
sites: readonly TargetSiteConfig[],
|
||||
): AdminSiteRow[] {
|
||||
return sites.map((site) => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
slug: site.slug,
|
||||
publishingRepository: site.publishing_rules.repository_url,
|
||||
productionBranch: site.publishing_rules.production_branch,
|
||||
activeVersionId: site.active_script_config_version_id ?? null,
|
||||
createdAt: site.created_at,
|
||||
updatedAt: site.updated_at,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { AdminSiteRow } from "./model";
|
||||
|
||||
type AdminSiteConfigTableProps = {
|
||||
rows: readonly AdminSiteRow[];
|
||||
};
|
||||
|
||||
export function AdminSiteConfigTable({ rows }: AdminSiteConfigTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return <p className="emptyState">No target sites yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Repository</th>
|
||||
<th>Branch</th>
|
||||
<th>Active config version</th>
|
||||
<th>Revisions</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.slug}</td>
|
||||
<td>{row.publishingRepository}</td>
|
||||
<td>{row.productionBranch}</td>
|
||||
<td>{row.activeVersionId ?? "none"}</td>
|
||||
<td>
|
||||
<Link href={`/admin/scripts?siteId=${row.id}`}>Versions</Link>
|
||||
</td>
|
||||
<td>{new Date(row.updatedAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { DEMO_ADMIN_EMAIL, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import { AdminScriptVersionsPanel } from "@/features/admin-scripts/ui";
|
||||
|
||||
type SearchParams = {
|
||||
siteId?: string;
|
||||
};
|
||||
|
||||
type AdminScriptsPageProps = {
|
||||
searchParams?: SearchParams;
|
||||
};
|
||||
|
||||
export default async function AdminScriptsPage({
|
||||
searchParams,
|
||||
}: AdminScriptsPageProps) {
|
||||
const sitesResponse = await fetchTargetSitesAsUser(DEMO_ADMIN_EMAIL);
|
||||
const siteOptions = sitesResponse.map((site) => site.site);
|
||||
|
||||
if (siteOptions.length === 0) {
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Admin Script Versions</h1>
|
||||
<p>No target sites configured yet.</p>
|
||||
</div>
|
||||
<Link href="/admin/sites">Open site configuration</Link>
|
||||
</header>
|
||||
<RoleNavigation role="ADMIN" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSiteId =
|
||||
searchParams?.siteId ?? siteOptions[0]?.id;
|
||||
const selectedSite =
|
||||
siteOptions.find((site) => site.id === selectedSiteId) ?? siteOptions[0];
|
||||
|
||||
const selectedResponse = await fetchTargetSite(selectedSite.id, DEMO_ADMIN_EMAIL);
|
||||
const activeSite = selectedResponse.site;
|
||||
const versionsResponse = await fetchScriptConfigVersions(
|
||||
selectedSite.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Admin Script Versions</h1>
|
||||
<p>Manage publishing YAML + transform scripts for each site.</p>
|
||||
</div>
|
||||
<Link href="/admin/sites">Open site configuration</Link>
|
||||
</header>
|
||||
<RoleNavigation role="ADMIN" />
|
||||
<section className="panel">
|
||||
<h2>Select site</h2>
|
||||
<ul className="inlineLinks">
|
||||
{siteOptions.map((site) => {
|
||||
const isActive = site.id === activeSite.id;
|
||||
return (
|
||||
<li key={site.id}>
|
||||
<Link
|
||||
href={`/admin/scripts?siteId=${site.id}`}
|
||||
className={isActive ? "activeLink" : undefined}
|
||||
>
|
||||
{site.name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<AdminScriptVersionsPanel
|
||||
site={activeSite}
|
||||
initialVersions={versionsResponse.versions}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { DEMO_ADMIN_EMAIL } from "@/shared/pipeline-api";
|
||||
import { AdminSiteCreateForm } from "@/features/admin-site-config-create/ui";
|
||||
import { buildAdminSiteRows } from "@/features/admin-site-config/model";
|
||||
import { AdminSiteConfigTable } from "@/features/admin-site-config/ui";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import { fetchTargetSitesAsUser } from "@/shared/pipeline-api";
|
||||
|
||||
export default async function AdminSitesPage() {
|
||||
const sitesResponse = await fetchTargetSitesAsUser(DEMO_ADMIN_EMAIL);
|
||||
const sites = sitesResponse.map((site) => site.site);
|
||||
const rows = buildAdminSiteRows(sites);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="pageHeader">
|
||||
<div>
|
||||
<h1>Admin Site Config</h1>
|
||||
<p>Manage target site publishing settings.</p>
|
||||
</div>
|
||||
<Link href="/">Back to dashboard</Link>
|
||||
<span className="roleBadge">Admin demo</span>
|
||||
</header>
|
||||
<RoleNavigation role="ADMIN" />
|
||||
<section className="panel">
|
||||
<h2>Existing sites</h2>
|
||||
<AdminSiteConfigTable rows={rows} />
|
||||
</section>
|
||||
<section className="panel">
|
||||
<AdminSiteCreateForm />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,21 @@ import type {
|
||||
ArticleDetailResponse,
|
||||
ArticleListResponse,
|
||||
CurrentUserResponse,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
TargetSiteConfigResponse,
|
||||
TargetSiteConfigCreateRequest,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export const DEMO_EDITOR_EMAIL = "editor@example.com";
|
||||
export const DEMO_ADMIN_EMAIL = "admin@example.com";
|
||||
const DEFAULT_LOCAL_BACKEND_URL = "http://localhost:8000";
|
||||
|
||||
type ApiRequestConfig = {
|
||||
userEmail?: string;
|
||||
};
|
||||
|
||||
export type ApiErrorPayload = {
|
||||
status: number;
|
||||
message: string;
|
||||
@@ -66,15 +75,17 @@ async function parseBody(response: Response): Promise<unknown> {
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
userEmail?: string,
|
||||
): Promise<T> {
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const effectiveUserEmail = userEmail ?? DEMO_EDITOR_EMAIL;
|
||||
const response = await fetch(buildApiUrl(path), {
|
||||
...options,
|
||||
headers: {
|
||||
...(!isFormData
|
||||
? { "Content-Type": "application/json" }
|
||||
: {}),
|
||||
"X-Demo-User-Email": DEMO_EDITOR_EMAIL,
|
||||
"X-Demo-User-Email": effectiveUserEmail,
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
cache: options.cache ?? "no-store",
|
||||
@@ -96,38 +107,124 @@ async function requestJson<T>(
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export function apiGet<T>(path: string): Promise<T> {
|
||||
return requestJson<T>(path, { method: "GET" });
|
||||
export function apiGet<T>(
|
||||
path: string,
|
||||
options?: ApiRequestConfig,
|
||||
): Promise<T> {
|
||||
return requestJson<T>(path, { method: "GET" }, options?.userEmail);
|
||||
}
|
||||
|
||||
export function apiPost<TRequest, TResponse>(
|
||||
path: string,
|
||||
body: TRequest,
|
||||
body: TRequest | undefined,
|
||||
options?: ApiRequestConfig,
|
||||
): Promise<TResponse> {
|
||||
return requestJson<TResponse>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const requestBody = body === undefined ? undefined : JSON.stringify(body);
|
||||
return requestJson<TResponse>(
|
||||
path,
|
||||
{
|
||||
method: "POST",
|
||||
...(requestBody === undefined ? {} : { body: requestBody }),
|
||||
},
|
||||
options?.userEmail,
|
||||
);
|
||||
}
|
||||
|
||||
export function apiPatch<TRequest, TResponse>(
|
||||
path: string,
|
||||
body: TRequest,
|
||||
options?: ApiRequestConfig,
|
||||
): Promise<TResponse> {
|
||||
return requestJson<TResponse>(path, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return requestJson<TResponse>(
|
||||
path,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
options?.userEmail,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCurrentUser(): Promise<CurrentUserResponse> {
|
||||
return apiGet("/api/me");
|
||||
return apiGet<CurrentUserResponse>("/api/me");
|
||||
}
|
||||
|
||||
export function fetchTargetSites(): Promise<TargetSiteConfigResponse[]> {
|
||||
return apiGet<TargetSiteConfigResponse[]>("/api/sites");
|
||||
}
|
||||
|
||||
export function fetchTargetSitesAsUser(
|
||||
userEmail: string,
|
||||
): Promise<TargetSiteConfigResponse[]> {
|
||||
return apiGet<TargetSiteConfigResponse[]>("/api/sites", { userEmail });
|
||||
}
|
||||
|
||||
export function fetchTargetSite(
|
||||
siteId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<TargetSiteConfigResponse> {
|
||||
return apiGet<TargetSiteConfigResponse>(`/api/sites/${siteId}`, {
|
||||
userEmail,
|
||||
});
|
||||
}
|
||||
|
||||
export function createTargetSite(
|
||||
request: TargetSiteConfigCreateRequest,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<TargetSiteConfigResponse> {
|
||||
return apiPost<TargetSiteConfigCreateRequest, TargetSiteConfigResponse>(
|
||||
"/api/sites",
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchScriptConfigVersions(
|
||||
siteId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<ScriptConfigVersionListResponse> {
|
||||
return apiGet<ScriptConfigVersionListResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/versions`,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function createScriptConfigVersion(
|
||||
siteId: string,
|
||||
request: ScriptConfigVersionCreateRequest,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<ScriptConfigVersionResponse> {
|
||||
return apiPost<ScriptConfigVersionCreateRequest, ScriptConfigVersionResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/versions`,
|
||||
request,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function activateScriptConfigVersion(
|
||||
siteId: string,
|
||||
versionId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<ScriptConfigVersionResponse> {
|
||||
return apiPost<unknown, ScriptConfigVersionResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/versions/${versionId}/activate`,
|
||||
undefined,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function rollbackScriptConfigVersion(
|
||||
siteId: string,
|
||||
versionId: string,
|
||||
userEmail = DEMO_ADMIN_EMAIL,
|
||||
): Promise<ScriptConfigVersionResponse> {
|
||||
return apiPost<unknown, ScriptConfigVersionResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/versions/${versionId}/rollback`,
|
||||
undefined,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArticles(): Promise<ArticleListResponse> {
|
||||
return apiGet<ArticleListResponse>("/api/articles");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
testDir,
|
||||
"../src/features/admin-scripts/model.ts",
|
||||
);
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
});
|
||||
|
||||
const moduleExports = {};
|
||||
new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const {
|
||||
buildAdminScriptVersionRows,
|
||||
defaultScriptVersionDraft,
|
||||
buildCreateScriptVersionPayload,
|
||||
parseJsonObject,
|
||||
} = moduleExports;
|
||||
|
||||
const rows = buildAdminScriptVersionRows([
|
||||
{
|
||||
id: "11111111-1111-1111-1111-111111111101",
|
||||
target_site_id: "22222222-2222-2222-2222-222222222222",
|
||||
version: 1,
|
||||
status: "ACTIVE",
|
||||
diff: { summary: "seed" },
|
||||
publishing_yaml_hash: "abc",
|
||||
transform_script_hash: "def",
|
||||
rollback_target_version_id: null,
|
||||
created_by: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
activated_at: "2025-01-01T01:00:00Z",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].isActive, true);
|
||||
assert.equal(rows[0].diff, '{"summary":"seed"}');
|
||||
|
||||
const payload = buildCreateScriptVersionPayload(defaultScriptVersionDraft());
|
||||
assert.equal(payload.activate, false);
|
||||
assert.ok(typeof payload.publishing_yaml === "string");
|
||||
assert.equal(payload.rollback_target_version_id, null);
|
||||
|
||||
assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 });
|
||||
assert.equal(parseJsonObject("[]"), null);
|
||||
assert.deepEqual(parseJsonObject(""), {});
|
||||
Reference in New Issue
Block a user