Documentation

Approval notices

Extend a VitNode Content Engine edit page so authors see pending and rejected submissions. Open the form on the proposed values, show a notice from the approval field, and pick the right message after a save that waits for a moderator.

When a container's publishing rules hold edits for a moderator, a successful save does not mean the change is live. This guide adds what an author needs on the edit page: the form opens on their pending proposal, a notice says what is waiting or why it was declined, and the toast after a save tells the truth.

The example is the Forum plugin's edit page, plugins/forum/src/pages/topic-edit-page.tsx. The code below is excerpts from that file.

Before you begin

Approval only exists for records filed in a container whose definition has policies. Check that your content type has all three:

  • A container with policies. The forum's forumContainers in plugins/forum/src/content/forums.ts sets policies: { defaults: { ... } }.
  • A record filed in it. forum.topic has forum: field.container({ definition: forumContainers, required: true }). A comment can inherit its topic's forum with field.relation({ target: () => topicContentType, container: true }).
  • A rule that waits. In the AdminCP, open /admin/containers/forum/forums, choose Publishing in a forum's row menu, set Edits to published posts to Wait for approval for topics and choose Save rules.

You also need an edit page that loads GET /{id}/edit and saves with useContentPublicWrite, like the Edit form, and a member who is not a moderator in that forum. The excerpts use two names from such a page:

  • EditableTopic is the record type the loader returns, Extract<TopicEditData, { state: "ready" }>["record"], declared like EditableArticle in Load the editable values.
  • TopicFormValues is the values type of the shared form, exported from plugins/forum/src/pages/topic-form.tsx like ArticleFormValues from the article form.

The newsroom tutorial's articles are not filed in a container, so approval is always null for them. A content type without a container keeps its drafts as plain drafts, even with create.status: "draft"; see Drafts and approval.

Three kinds of values

With approval on, an edit page handles three different things. Keep them apart, or the author will think a change is live when it is not:

WhatWhere it comes fromReaders see it
Live valuesrecord.values from GET /{id}/edit and PATCH /{id}Yes
Proposed valuesrecord.approval.values, when approval.status is "pending" and approval.kind is "edit"No, until a moderator approves
A save that waitsPATCH /{id} answers 200 with approval.status: "pending"; values is still the live recordNo

Open the form on the proposal

When the author reopens a page whose edit is pending, the form should show what they sent, not the live text. approval.values is untyped, so parse the fields the form knows and fall back to the live value for the rest:

plugins/forum/src/pages/topic-edit-page.tsx (excerpt)
import { zodRichTextDocument } from "@vitnode/core/content";
import { z } from "zod";

import type { TopicFormValues } from "./topic-form";

const zodProposedValues = z
  .object({
    body: zodRichTextDocument.optional(),
    title: z.string().optional(),
  })
  .catch({});

const formValuesOf = (record: EditableTopic): TopicFormValues => {
  const { approval } = record;
  const proposed =
    approval?.status === "pending" && approval.kind === "edit"
      ? zodProposedValues.parse(approval.values ?? {})
      : {};

  return {
    body: proposed.body ?? record.values.body,
    title: proposed.title ?? record.values.title,
  };
};

Use formValuesOf(record) wherever the basic edit form used record.values for its defaults.

Show a notice from the server's answer

approval is null when nothing waits. Otherwise it is a pending submission, or a rejected one nothing has replaced yet. The notice reads that object and nothing else:

plugins/forum/src/pages/topic-edit-page.tsx (excerpt)
import type { ContentApprovalState } from "@vitnode/core/content";

import {
  Alert,
  AlertDescription,
  AlertTitle,
} from "@vitnode/core/components/ui/alert";
import { HrefLink } from "@vitnode/core/tanstack/link";
import { HourglassIcon, XCircleIcon } from "lucide-react";
import { useTranslations } from "use-intl";

import { FORUM_PENDING_HREF } from "./forum-links";

/** Reads only the server's `approval`: neither the URL nor a draft status adds a notice. */
const ApprovalNotice = ({
  approval,
}: {
  /** Only what the notice reads, so the edit route's JSON answer fits as is. */
  approval: null | Pick<ContentApprovalState, "kind" | "reason" | "status">;
}) => {
  const t = useTranslations("@vitnode/forum.topics.approval");

  if (approval?.status === "rejected") {
    return (
      <Alert icon={<XCircleIcon />} variant="destructive">
        <AlertTitle>{t("rejected.title")}</AlertTitle>
        <AlertDescription>
          {approval.reason
            ? t("rejected.reason", { reason: approval.reason })
            : t("rejected.desc")}
        </AlertDescription>
      </Alert>
    );
  }

  if (approval?.status === "pending" && approval.kind === "edit") {
    return (
      <Alert icon={<HourglassIcon />} variant="info">
        <AlertTitle>{t("pending_edit.title")}</AlertTitle>
        <AlertDescription>{t("pending_edit.desc")}</AlertDescription>
      </Alert>
    );
  }

  if (approval?.status === "pending" && approval.kind === "create") {
    return (
      <Alert icon={<HourglassIcon />} variant="info">
        <AlertTitle>{t("pending_create.title")}</AlertTitle>
        <AlertDescription>
          {t("pending_create.desc")}{" "}
          <HrefLink className="font-medium underline" href={FORUM_PENDING_HREF}>
            {t("pending_create.link")}
          </HrefLink>
        </AlertDescription>
      </Alert>
    );
  }

  return null;
};
  • Rejected shows the moderator's reason when there is one. The author can edit and save again, which opens a new submission.
  • Pending edit needs status: "pending" and kind: "edit", and says readers still see the published version.
  • Pending create needs status: "pending" and kind: "create", which means a new topic waits for its first approval. It links to the author's list of posts awaiting approval, /forum/me/pending.
  • Nothing else. approval: null renders no notice, even for a draft. A draft is not a submission, so the notice never looks at status, and it never reads the URL.

The approval state comes from the server. The route's loader awaits GET /{id}/edit before the page renders, and its answer carries approval. Every save replaces it with the one PATCH /{id} returns.

Keep the answer's state after a save

PATCH /{id} answers with the record's approval, slug and status. Keep them in state, so the notice and the View topic link follow every save, and pick the toast from approval:

plugins/forum/src/pages/topic-edit-page.tsx (excerpt)
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";

import { invalidateForumTopics } from "./forum-queries";

// Inside the form component, next to `useContentPublicWrite`:
const queryClient = useQueryClient();
const [current, setCurrent] = useState({
  approval: record.approval,
  slug: record.slug,
  status: record.status,
});
const [defaults, setDefaults] = useState(() => formValuesOf(record));

// In onSubmit, after `const updated = await write.submit(...)`:
if (!updated) return;

setCurrent({
  approval: updated.approval,
  slug: updated.slug,
  status: updated.status,
});
setDefaults(values);
form.reset(values);
await invalidateForumTopics(queryClient);

if (updated.approval?.status === "pending") {
  toast.info(t("edit.pending.title"), { description: t("edit.pending.desc") });
} else {
  toast.success(t("edit.success.title"), {
    description: t("edit.success.desc", { title: values.title }),
  });
}

Render <ApprovalNotice approval={current.approval} /> above {write.conflict} and the form. invalidateForumTopics refreshes the forum's cached topic lists; in your plugin, invalidate your own list's query key, as the Edit form does. The server checks a proposal against the record's version like any edit, so the conflict alert keeps working.

Add the labels

The forum keeps the notice labels under topics.approval and the toast under topics.edit in plugins/forum/src/locales/en.json:

plugins/forum/src/locales/en.json (excerpt)
{
  "@vitnode/forum": {
    "topics": {
      "approval": {
        "rejected": {
          "title": "A moderator declined this",
          "desc": "Your submission was not approved. You can edit it and send it again.",
          "reason": "Reason: {reason}"
        },
        "pending_edit": {
          "title": "Your changes are waiting for approval",
          "desc": "Readers still see the published version. You are looking at your proposed changes."
        },
        "pending_create": {
          "title": "Waiting for a moderator",
          "desc": "Your topic goes live as soon as a moderator approves it. You can still polish it here.",
          "link": "See all your posts awaiting approval."
        }
      },
      "edit": {
        "pending": {
          "title": "Sent for approval",
          "desc": "A moderator will review your changes. Readers see the published version until then."
        },
        "success": { "title": "Topic saved", "desc": "\"{title}\" is up to date." }
      }
    }
  }
}

Check the result

Sign in as a member who wrote a published topic in the forum that waits for edits, and open /forum/topics/{id}/edit.

  1. Change the title and click Save changes. Sent for approval appears, the notice says your changes wait, and the form keeps your new title. The topic's public page still shows the old title.
  2. Reload the edit page. The form opens on your proposal, not on the live title.
The Forum plugin's Edit topic page with the notice Your changes are waiting for approval above the form, which shows the proposed title, and a Sent for approval toast
  1. As a moderator with can_approve in that forum, reject the submission with a reason in the ModCP. Reload the author's edit page. The notice shows the reason, and the form shows the live text again.
The Forum plugin's Edit topic page with the notice A moderator declined this and the reason Please keep the title about the whole editor, not only the toolbar, above the title and message fields
  1. Save another change and approve it instead. After a reload the notice is gone, and both the form and the public page show the new title.
  2. Open a topic with nothing pending, then one that staff unpublished in the AdminCP, which is a draft with approval: null. Neither shows a notice, even with ?submitted=1 added to the URL. Older Forum links carried that flag, and the edit page now ignores it.