DEV Community

Cover image for Create an entry approval workflow with Statamic Revisions
Lakkes for visuellverstehen

Posted on

Create an entry approval workflow with Statamic Revisions

When managing entries in your Statamic application that gather external data, such as from APIs, you may find it necessary to implement a data control step before publication. Statamic offers a useful tool for this purpose known as the revisions feature, specifically focusing on working copies.

In the following sections, I'll guide you through the process of creating an Approval Mechanism for Statamic.

This article is structured as follows:

Who is this article for?

This article is primarily aimed at Statamic developers who are already acquainted with Statamic's core concepts and have a basic understanding of PHP and Laravel. To fully comprehend and implement the Approval Mechanism discussed here, readers should be familiar with key Statamic elements like entries and blueprints.

Additionally, a basic grasp of Vue.js for handling user interface components and API integration knowledge will prove beneficial in following concepts presented in this article.

I would recommended that you also have a basic understanding of extending Statamic, but if you're new to this, it's a good idea to explore those concepts beforehand for a smoother learning experience.

You need Statamic Pro to enable the revisions feature.

What we are about to do

In this article, our primary objective is to create an Approval Mechanism within Statamic, emphasizing the process of managing entries and enabling a structured review and feedback workflow. While we briefly touch on data import as context, our main focus lies in developing this mechanism. We'll explore how to efficiently flag entries as 'Needs Review,' facilitate feedback from CMS authors through a built-in feedback form, and dynamically update entry statuses for better organization.

Additionally, we'll demonstrate how to log feedback and maintain feedback history as part of this comprehensive solution. For illustration, we'll use a 'persons' collection where each person is associated with a unique 'external_service_id,' linking them to external data sources.

What's a working copy?

Working copies serve as a temporary version of an entry, generated when you edit and save an existing entry. You then have the option to create a revision or proceed with publication. Should you opt for a revision or publication, the working copy will be discarded. Conversely, choosing to save your changes ensures that the working copy is promptly updated.

By default working copies are saved inside the revisions folder. The path can be set in the config/statamic/revisions.php file. By default it's set to storage_path('statamic/revisions').

Let's bring in the data!

We assume we already have some existing persons in our collection. Inside our import method we want to update or create a working copy of each entry.

foreach ($personEntries as $personEntry) {
    if ($personEntry->hasWorkingCopy()) {
        // Update working copy
    else {
        // Create working copy
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's implement these methods. We are assuming that the new data comes in an array that represents the data structure of our person blueprint. For example:

$newData = [
    'name' => 'Smeagol',
    'diet' => 'juicy sweet fish',
    'job_title' => 'Tourist Guide',
];
Enter fullscreen mode Exit fullscreen mode

Creating the working copy

function createWorkingCopy(Entry $entry, array $data)
{
    // Set the new data
    foreach ($data as $key => $value) {
        $entry->set($key, $value);
    }

    // Create a working copy from the entry
    $workingCopy = $entry->makeWorkingCopy();

    // Save the working copy
    $workingCopy->save();
}
Enter fullscreen mode Exit fullscreen mode

Updating the working copy is a little more complicated since the data can't be set directly on the working copy. We have to get the data from the working copy, update it and then save it again.

function updateWorkingCopy(Entry $entry, array $newData)
{
    // Get current data from the data attribute and merge it with the new data.
    $currentData = $entry->workingCopy()->attribute('data');
    $updatedData = [...$currentData, ...$newPureData];

    $entry
        ->workingCopy()
        ->attribute('data', $updatedData)
        ->save();
}
Enter fullscreen mode Exit fullscreen mode

So, at the moment, we always create or update a working copy and overwrite a possible existing one. In this case, that's okay, but you may want to check if there is already a working copy, and if so, create a new revision instead of overwriting the working copy. But that results in potentially a lot of revisions. So you have to decide what's best for you. I will only cover the working copy case here.

Additionally, you should have a field in your entries blueprint where you save the last imported date to check if the incoming data is newer than the last import. If not, you can skip the entry. That, of course, presupposes that your external data also provides some kind of 'updated_at' field.

Let authors know that there's something to review

What we did before is basically everything you need to have a simple approval mechanism where new data gets saved inside a working copy to be reviewed. But we want to give the CMS authors a hint that there is something to review. We will do this by adding a status field.

What we need is a custom field type that we can use in our blueprint. I won't go into detail here about how to create a custom field type. You can read about it in the Statamic documentation.

We will create a custom field type called 'approval_status' that will have three possible values: 'needs_review,' 'approved,' and 'feedback_sent.'

We will add some methods to our ApprovalStatus.php fieldtype class to check the status.

public function preProcessIndex($data)
{
    return $this->getStatus();
}

public function preload()
{
    return $this->getStatus();
}

protected function getStatus(): array
{
    $entry = $this->field()->parent();

    return ApprovalStatus::get($entry);
}
Enter fullscreen mode Exit fullscreen mode

As you can see, it can be better to outsource the logic to a separate class. We will create an ApprovalStatus class that will have a static method to get the status. This method will use a variable $feedbackDate. This variable will be set when the CMS author gives feedback and deleted when the working copy is published. We will cover this later.

Here's a little diagram that visually explains the logic behind the status check:

The logic behind the approval status

… and the corresponding code:

public static function get(Entry $entry): array
{
    $workingCopyData = $entry->workingCopy()?->attribute('data');
    $feedbackDate = $workingCopyData['feedback_date'];

    // The person has no working copy and is published, so there's noting to be reviewed.
    if (! $entry->hasWorkingCopy() && $entry->published()) {
        return ['status' => 'approved'];
    }

    // The person has a working copy and has no feedback date, so a review was sent.
    if ($entry->hasWorkingCopy() && $feedbackDate) {
        return ['status' => 'feedback_sent'];
    }

    // The person has a working copy and has no feedback date, so it needs a review.
    if ($entry->hasWorkingCopy() && ! $feedbackDate) {
        return ['status' => 'needs_review'];
    }
}
Enter fullscreen mode Exit fullscreen mode

The Vue components to our ApprovalStatus field type class are pretty simple. They get the status with the help of the preload() and the preProcessIndex() methods.

// ApprovalStatus.vue

<template>
    <div>{{ this.meta.status }}</div>
</template>
Enter fullscreen mode Exit fullscreen mode
// ApprovalStatusIndex.vue

<template>
    <span>{{ this.value.status }}</span>
</template>
Enter fullscreen mode Exit fullscreen mode

You can also add your classes and pass a color to your status array to style the status in the control panel. You can find more information about index field types here: Statamic Index Fieldtypes

Giving Feedback

Feedback Workflow Step 1

Feedback Workflow Step 2 and 3

Feedback Wokflow Step 4

Create a feedback form

The form is also a custom fieldtype. It will have a textarea and a button to submit the feedback. We assume that the person entry has an email stored. We can write our feedback into the text field and with the click on the button, we will make a post request to a custom route that takes care of error handling and sending the mail. After sending the mail, we want to disable the form and the button to prevent multiple feedbacks.

This is how the Vue component can look like:

<template>
    <div>
        <textarea v-model="message"></textarea>
        <div>
            <div>Send a mail to {{ this.mail }}</div>
            <button :disabled="mailSent" @click="sendEmail">
                <span v-if="this.mailSent">E-Mail is on the way</span>
                <span v-else>Send E-Mail</span>
            </button>
        </div>
    </div>
</template>

<script>
import axios from 'axios';

export default {
    mixins: [Fieldtype],
    inject: ['storeName'],

    data() {
        return {
            message: '',
            mailSent: false,
        };
    },

    computed: {
        mail: function () {
            return this.$store.state.publish[this.storeName].values.email;
        },
        pid: function () {
            return this.$store.state.publish[this.storeName].values.pid;
        },
    },

    methods: {
        sendEmail() {
            this.$progress.start('review_mail');

            axios.post('/admin/person/review/send', {
                message: this.message,
                email: this.mail,
            })
                .then(response => {
                    this.$progress.complete('review_mail');
                    this.$toast.success(`E-Mail sent to ${this.mail}!`);
                    this.mailSent = true;
                })
                .catch(error => {
                    this.$progress.complete('review_mail');
                    this.$toast.error(error.response.data.error);
                    this.mailSent = false;
                });
        }
    }
};
</script>
Enter fullscreen mode Exit fullscreen mode

Tracking feedback and dates

To allow a better overview of previous sent feedback, you can log your feedback in a replicator field below the feedback form. Simply add some code to the method that handles your post request from the form component above. Let's assume this method is inside the same class as the create and update working copy methods.

function saveFeedback(string $entryId, string $feedbackMessage)
{
    $entry = Entry::find($entryId);

    $currentFeedbackLog = $entry->workingCopy()->attribute('data')['feedback_logs'] ?? [];
    $newLog = [
        'type' => 'log',
        'enabled' => true,
        'id' => RowId::generate(),
        'mail' => $feedbackLog,
        'time' => now()->format('d.m.Y H:i:s'),
    ];

    $updatedLog = array_merge([$newLog], $currentFeedbackLog);

    $this->updateWorkingCopy($entry, [
        'feedback_logs' => $updatedLog,
        'feedback_date' => now()->format('Y-m-d\TH:i:s.uP'),

    ]);
}
Enter fullscreen mode Exit fullscreen mode

Handling publication

To finish up the approval mechanism, we just have to delete the feedback date automatically when the entry gets published. We can achieve this by listening to the EntrySaved vent. We will create a listener class that will check if the entry is from the persons collection and has set a feedback date and if so, delete the feedback date.

public function handle(object $event): void
{
    $entry = $event->entry;
    $collectionHandle = $entry->collection->handle;

    if (! $collectionHandle === 'persons') {
        return;
    }

    if (! $entry->feedback_date) {
        return;
    }

    $entry->set('feedback_date', null)->saveQuietly();
}
Enter fullscreen mode Exit fullscreen mode

This is how the Feedback UI could look like:

The entry feedback form, status and log

The index view with status

Conclusion

Great! 🎉 We now have a simple approval mechanism for our Statamic application. Of course, you can extend this mechanism to your needs. For example, you can add a field to your entries blueprint where you can set the email address of the person that should review the entry or for the person that should review it. It's also very handy to add a filter to your index view to filter for entries that need a review.

When handling multisites, you should think about if you want to have a separate approval mechanism for each site or if you want to have a global one. In my case, I disabled the review form for any other language than the default one, and the authors are always sending reviews for all localizations in one mail. When creating and updating the working copy, you have to update each localization separately. Take a look at the $entry->descendants() method to get all localizations of an entry.

I hope this article was helpful for you. If you have any questions or suggestions, please let me know in the comments. 👋

Top comments (0)