Development

Building Content Teams with WP User Groups and Custom User Roles

The WordPress user roles and capabilities system is built to be pretty flexible. There’s a hierarchy of responsibilities in place that can be used to inform a content workflow from lower on the totem pole to higher. But sometimes those user roles aren’t quite sufficient to perform the particular kinds of tasks (but not other tasks) that you want your users to perform. Or perhaps you need to create subdivisions within your roles to create “content teams” of users–that’s not really supported by WordPress out of the box. I recently built a fairly complex content team system based on John James Jacoby‘s excellent WP User Groups plugin and I’m going to show you how it works–and how to extend it–in this post.

This is going to be extremely technical, and you probably won’t have need for this specific functionality, but hopefully it will give you an idea of some of the cool things you can do with the tools at hand.

User Roles at a Glance

robot-soccer-goal

Have you ever really thought about the roles and capabilities system in WordPress? Most of the time, it’s invisible–just a thing that you know is there but you don’t really ever deal with. If you work with WordPress on a daily basis, you probably know that a Subscriber doesn’t really have any privileges; they can just log into the site and update their profile. Administrators have full keys to the castle, those are the users that can do everything. Somewhere in the middle there’s Editors, who are really similar to Administrators, except they can’t edit settings or install plugins or themes. And then there’s Authors and Contributors and…well, they do something but outside of creating content, we’re not really sure what the difference between the two is…

I think that’s probably how most people see the user roles system. Those with a bit more insight into technical systems and access rights in general might assume, since this is a hierarchical system, that these roles are based on an access level system; that is, at the lowest level you can do these specific tasks, and as you go up the ladder, additional tasks are added that you have access to. In fact, WordPress used to use an access level system, but doesn’t anymore. The problem with access level systems is it’s very hard to make exceptions for specific tasks. For instance, what if you want to grant a user at level seven some capabilities that are only accessible at level nine but you don’t want to promote the user? You can’t in an access level-based system. The other problem with this system is that it doesn’t really account for custom roles–what if you wanted to create a totally different role with different rights and responsibilities? Where do they fit in the user level system? They don’t.

If WordPress doesn’t use an access level-based system, maybe they use some form of access control list (ACL) system. If you’ve ever managed an IRC channel or a BBS server (I realize I’m dating myself here…), this type of access will make sense to you. In an ACL system, you can give users access rights to specific tasks higgledy-piggledy pretty much at your whim. Their specific role doesn’t matter so much as the list of capabilities that they have. In WordPress, this would look like this: I want Bob the Editor to be able to update plugins for me, so I go into his account and I grant him the update_plugins capability. Great, now Bob can update my plugins for me. Bob’s not an administrator, but he has a specific administrative capability to help me do my job as the administrator.

This isn’t how WordPress works either, not usually, although you could engineer WordPress to work this way and there are plugins that can help with this. Rather, WordPress uses a Role Based Access Control system. Role Based Access Control (RBAC) extends the idea of the original system that handled access control in WordPress (that of user levels), but rather than checking if a user is a particular user level, WordPress roles have a number of capabilities assigned at each level. The number and complexity of the capabilities ascends from the lowest (read only for Subscribers) to the highest (dozens including manage_options for Administrators). In a way, it takes the best of both worlds: You can still have a hierarchical structure, but you also have the ability to grant specific capabilities to specific users.

When working with user roles and capabilities, however, it’s good to keep this foundation of how WordPress handles them natively in mind. As I said, I could just grant Bob, specifically, the capabilities that I want his user account to have. But on a large enough scale, that kind of system gets incredibly unwieldy–you never truly know what users have what capabilities and responsibilities without cross-referencing with their specific list of capabilities. If you have 100 people in the admin, and each is responsible for a different part of the site, and each has a completely unique set of capabilities, you can see how this can get a bit out of hand.

When I’m working with custom user roles and custom capabilities, I always like to base them off the core roles system within WordPress. That is, I will use hierarchical capabilities, where necessary, and make sure to grant those capabilities to other, core WordPress user roles, so my custom user roles aren’t out on an island, the only users in the system able to perform a specific function.

Creating Groups of Users with WP User Groups

robot-soccer-fall

Let’s get down to the nitty-gritty of building a system to handle different “content teams.”

Here’s the elevator pitch: I need a solution to allow different teams of users to create content for different parts of the site. They cannot create content that is outside of their own team. They cannot edit content that is outside of their own team. They can view (on the front-end of the site) content submitted by anyone, but they are only responsible for those things that align with the teams they are members of. Within these teams, there are two levels of users, content creators and content editors. The creators can only submit the content. They can’t actually publish the content. The content editors can actually approve and publish the content. Above these two user types there are global content creators and editors who can create and edit any kind of content–these can just be core WordPress users. Additionally, when content editors approve or “publish” their team’s content, it only appears on their team’s section of the website by default; it doesn’t exist globally until it has been reviewed by a global content editor or administrator.

Sounds like fun, right? Let’s start with JJJ’s WP User Groups plugin.

WP User Groups is part of the Stuttter group of plugins that John has been working on for the past year or so, which are all very small plugins that perform very simple, singular tasks. In this case, the ability to group users together. The way it does this is by using custom taxonomies and taxonomy terms to associate to users.

    /**
     * Registers the taxonomies to be used by WP User Groups as User Taxonomies.
     *
     * In order to create teams that can work with certain types of content,
     * we need to be able to group users into teams. Uses WP User Groups.
     *
     * @link https://github.com/stuttter/wp-user-groups
     */
    function wds_register_user_taxonomy() {
        // Make sure that WP_User_Taxonomy class exists
        if ( ! class_exists( 'WP_User_Taxonomy' ) ) {
            return;
        }

        // Create the new user taxonomy.
        new WP_User_Taxonomy( 'content_team', 'users/content-team', array(
            'singular' => __( 'Team',  'wds' ),
            'plural'   => __( 'Teams', 'wds' ),
        ) );

    }
        add_action( 'init', 'wds_register_user_taxonomy' );

    /**
     * Register a new taxonomy for content teams to be used as a User Taxonomy.
     * We're using John Billion's Extended Taxonomies library to simplify the taxonomy
     * creation.
     *
     * @link https://github.com/johnbillion/extended-taxos
     */
    function wds_register_taxonomies() {
        /*
         * The Content Team taxonomy.
         *
         * This is a user taxonomy that is used to create "teams" of users based on 
         * the area(s) they specialize in. Members of a Content Team can only see 
         * documents within their own team.
         */
        register_extended_taxonomy( 'content_team', array(), array(), array(
            'singular' => __( 'Team', 'wds' ),
            'plural'   => __( 'Teams', 'wds' ),
            'slug'     => 'content-team',
        ) );
    }
        add_action( 'init', 'wds_register_taxonomies', 90 );

Creating a user taxonomy is really more like associating a taxonomy to act as a user taxonomy. The distinction is important–the taxonomy needs to exist first, and it needs to not be a taxonomy that you’re using for content (technically you can associate content to a user taxonomy, but you aren’t able to query content by a user taxonomy, only users, so they’re best left separate). In the snippet above, I’m creating a normal taxonomy and using the WP_User_Taxonomy class to make that taxonomy a user taxonomy.

By default, WP User Groups comes with two pre-existing User Taxonomies: Types and Groups. They’re there so the plugin actually can be shown to be doing something out of the box and also there as sort of proof-of-concepts to give you an idea of how User Taxonomies are created. It’s really easy to remove these default user taxonomies and, if you aren’t going to be using them, that’s probably a thing you should do. All you need to do to remove them is add a remove_action in your plugin.

/* Remove default User Taxonomies */
remove_action( 'init', 'wp_register_default_user_group_taxonomy' );
remove_action( 'init', 'wp_register_default_user_type_taxonomy' );

That sets up my initial groups that I’ll use to associate with content teams. Now let’s create some custom user roles for those users on those teams.

Creating Custom Hierarchical User Roles

robot-soccer-obama

For my content teams plugin, I needed two basic user levels: A user who could submit content for review, and a user who could review that content and approve it. These more or less correspond to the WordPress user roles of contributor and editor, so we can reference the list of capabilities granted to those users and base our capabilities for our custom user roles off that.

This gives us a list of capabilities that looks like this:

        // Content Creators are set up like Contributors.
        add_role( 'content_creator', __( 'Content Creator', 'wds' ), array(
            'read'                            => true,
            'delete_posts'                    => true,
            'edit_posts'                      => true,
            'upload_files'                    => true, // Let them upload files, they'll need it.
            'edit_pages'                      => true, // Able to edit WordPress pages.
        ) );

        // Content Approvers are set up like Editors with fewer caps.
        add_role( 'content_approver', __( 'Content Approver', 'wds' ), array(
            'delete_posts'                    => true,
            'delete_published_posts'          => true,
            'edit_others_posts'               => true,
            'edit_posts'                      => true,
            'edit_published_posts'            => true,
            'publish_posts'                   => true,
            'read'                            => true,
            'unfiltered_html'                 => true,
            'upload_files'                    => true,
        ) );

This should be run on plugin activation or run as some other kind of single-use function. add_role creates the role and sets up the basic capabilities of how that role will behave. We can (and will) adjust those capabilities later. You can add any capabilities from the list of capabilities within WordPress that you like here, or introduce new, custom capabilities here that aren’t used in WordPress. As you can see, there’s two areas where my Content Creator role veers away from what standard contributors can do, and that’s by allowing them to upload files and edit pages.

But I also want to add some custom capabilities, not only to my new user roles, but also my existing user roles. These capabilities will define who can edit content that is team-specific and who can edit content that is global. So I’ll create a couple new capabilities to handle those rights that I can then build methods and functions around later to check who has what caps.

    /**
     * Take a role and return the new capabilities that should be added to that role.
     * @param  string $role Any role used by the Content Teams plugin.
     * @return array        Array of new capabilities added to that role.
     */
    public function role_to_caps_map( $role = '' ) {
        // Bail if no role was passed.
        if ( '' == $role ) {
            return false;
        }

        // Check if the role passed is one we're using.
        if ( ! $this->using_role( $role ) ) {
            return false;
        }

        // Map the new capabilities to user roles.
        $caps_map = array(
            'administrator' => array(
                'edit_team_content',   // Able to view content from teams.
                'edit_global_content', // Able to view all content, regardless of team.
            ),
            'editor' => array(
                'edit_team_content',   
                'edit_global_content', 
            ),
            'contributor' => array(
                'edit_team_content',   
                'edit_global_content', 
            ),
            'site_approver' => array(
                'edit_team_content',   
                'team_publish_posts',  
            ),
            'site_creator' => array(
                'edit_team_content',   
            ),
        );

        // Return the new capabilities for the given role.
        return $caps_map[ $role ];
    }

This function doesn’t do much by itself other than return an array of capabilities for a given user role. However, I’m stopping here to show the two new capabilities I’m adding: edit_team_content and edit_global_content. edit_team_content is granted to everyone (at least everyone I care about, namely my custom roles, as well as WordPress contributors, editors and administrators) — anyone can edit content that belongs to a specific team. edit_global_content is only granted to the default WordPress user roles; we’re using those existing roles to be the default, master level administrative roles able to not only create content specific to a team but, in the case of a WordPress Editor or Administrator, also edit and publish that content. We haven’t gotten to associating content to specific teams yet, but that’s coming. Let’s combine this map with something that actually adds or removes these capabilities.

    /**
     * Adds or removes the new capabilities required for Content Teams.
     *
     * @param  string $action The desired action. Either 'add' or 'remove'.
     */
    public function adjust_caps( $action = '' ) {
        if ( ! in_array( $action, array( 'add', 'remove' ) ) ) {
            return;
        }

        $adjust_cap = $action . '_cap';

        // Loop through all the content team roles.
        foreach ( $this->content_team_roles() as $this_role ) {
            // Check if the role exists and save the role to a variable.
            if ( $role = get_role( $this_role ) ) {
                // Loop through each cap for that role.
                foreach ( $this->role_to_caps_map( $this_role ) as $cap ) {
                    // Add the cap to the role.
                    $role->$adjust_cap( $cap );
                } // Ends caps loop.
            } // Ends role check.
        } // Ends role loop.
    }

    /**
     * Triggered on activation, adds the new capabilities for Content Teams.
     */
    public function add_caps() {
        $this->adjust_caps( 'add' );
    }

    /**
     * Triggered on deactivation, removes the new capabilities for Content Teams.
     */
    public function remove_caps() {
        $this->adjust_caps( 'remove' );
    }

What’s going on here? Well, first we’re making sure that the only action passed to adjust_caps is either “add” or “remove”, then we’re mapping that action to a variable function. Variable functions are fun and can help keep your code DRY (Don’t Repeat Yourself). In this case, we’re using $adjust_cap as a variable function to stand in for either add_cap or remove_cap depending on which one was passed to our adjust_caps function. Then we run a loop through all of the roles we care about and add or remove the relevant capabilities for that role. (The nested foreach loop could probably be simplified, but you get the idea–for each role that we care about, make sure the role exists, then loop through and add all the capabilities that role should have.)

At the end, there’s some helper methods used for adding or removing capabilities. It’s important to remember that just because you wrote code that adds a capability to a user role your user role might not have that capability unless there’s another function or method that’s triggered the adding or removing of that capability. It sounds obvious but you’d be surprised how easy it is to forget and assume that the capability is already there–especially when you’re working with a local and a separate test, lab, or production environment. It’s not–at least, it’s not unless you’ve deactivated and reactivated your plugin (assuming you’ve got those capabilities added on activation). So these add/remove caps functions can be triggered on activation or whenever we might need them (like if we needed to reset the capabilities manually).

That pretty much does it for our custom user roles. There will be one more capability we’ll add later, but for now, let’s move on to associating content to teams.

Creating Content Teams

The idea behind content teams is that, let’s say you’ve got a tech blog with a lot of writers. And on this tech blog you’ve got pretty distinct categories, like say Apple, Microsoft, Google, Mobile, Social Media, Gadgets, etc. Maybe you’ve got some crossover with some of your writers (for instance if a writer contributed content to both the Mobile and Apple teams), but, for the most part, let’s assume these teams are pretty siloed. What I want to do is give my members of these teams custom views of the WordPress admin that will only show them or allow them to edit content that is within their team (or one of their teams if they are on multiple teams).

The way I did this was by using a taxonomy for the content itself, and that taxonomy was mirrored as a User Taxonomy. So whenever a new “category” taxonomy term was created, a new content team was created, too. For those users who are content creators or approvers (e.g. the ones on the teams), their team’s taxonomy term would automagically be associated with the content they are creating. Then we can filter the post list in the admin by what the logged in user should be able to see based on his or her team.

In my case, though, I needed to go one step farther. For each content team there should be a sort of landing page for that team. This is where, by default, all content that was written for that team should be published first. Content can, additionally, be displayed sitewide but not all content will necessarily be everywhere. It will, however, all be on the team’s landing page.

Custom post types have better WordPress hooks for editing and deleting than taxonomies, so using a post type here actually helps us out a bit. Rather than hooking into when a term is edited or deleted, we can hook into when a post type is saved or trashed. For the initial term creation though, we’ll use the create_{$taxonomy} hook to create a new term in our User Taxonomy that’s a copy of the term we just created in our content taxonomy.

    /**
     * Create a content team term when a content area term is created.
     *
     * @param  int $term_id The term ID.
     * @param  int $tt_id   The term taxonomy ID.
     */
    function wds_create_content_team( $term_id, $tt_id ) {
        $term = get_term_by( 'id', $term_id, 'content_area' );

        if ( ! $term ) {
            return;
        }

        wp_insert_term( $term->name, 'content_team', array(
            'description' => $term->description,
            'slug'        => $term->slug,
        ) );
    }

    add_action( 'create_content_area', 'wds_create_content_team', 10, 2 );

In the code above, whenever a new content_area term is created, we create a new content_team term. You should remember that content_team is the User Taxonomy we’re using for our teams. We’ll assume when our post type for that team is created, that it will create a new taxonomy content_area taxonomy term.

    /**
     * Runs on the save_post hook to handle adding terms for Content Areas.
     *
     * @param int    $post_id The ID of the Content Area post
     * @param object $post    The post object.
     */
    function wds_save_taxes( $post_id, $post ) {
        // If this isn't the Content Area CPT, then bail.
        if ( 'content_area_cpt' !== $post->post_type ) {
            return;
        }


        // Add term for the Content Area.
        if ( 'publish' == $post->post_status ) {
            $this->add_content_area_term( $post->ID, $post->post_title );
        }
    }
    add_action( 'save_post', 'wds_save_taxes', 10, 2 );


    /**
     * Inserts or updates a Content Area term.
     *
     * @param string $slug   The post slug.
     * @param string $title  The post title.
     */
    function wds_add_content_area_term( $slug, $title ) {
        $term = get_term_by( 'slug', $slug, 'content_area', OBJECT );

        if ( ! $term ) {
            wp_insert_term(
                esc_html( $title ),
                'content_area',
                array( 'slug'   => $slug )
            );
        } else {
            wp_update_term(
                $term->term_id,
                'content_area',
                array(
                    'name'   => esc_html( $title ),
                    'slug'   => esc_attr( $slug ),
                )
            );
        }
    }

Now, when a new “content area” post type is created, it also creates a new content_area taxonomy term which, in turn, triggers the creation of a content_team term. Now let’s deal with altering the views for our content creators and approvers.

pre_get_posts is a handy WordPress hook that alters the main WordPress query object before it’s rendered on the page. It’s generally the last place where the query can be altered before you see it (the absolute last place is through query_posts on the actual template, but this is discouraged because query_posts is altering the actual $wp_query global which can be dangerous). pre_get_posts has the added bonus of being a thing that can run in the WordPress admin or on the front-end of a site. This helps us out because this means we can filter the posts for our teams based on their user taxonomy before they are even displayed on the Edit Posts list.

    /**
     * Handles the magic filtering of Program Areas by Content Team.
     *
     * @param  class $query WP_Query that we're modifying.
     */
    function wds_filter_content_areas( $query ) {
        if ( ! is_admin() ) {
            return;
        }

        // Get the post type.
        $current_post_type = $query->get( 'post_type' );

        // Make sure we're on the right post type edit page.
        if ( in_array( $current_post_type, wds_content_area_post_types() ) ) {

            if ( wds_is_site_content_editor() ) {

                $teams = ( wp_get_terms_for_user( get_current_user_id(), 'content_team' ) ) ? wp_get_terms_for_user( get_current_user_id(), 'content_team' ) : array();
                $areas = array();

                foreach ( $teams as $team ) {
                    $areas[] = wds_get_term_id_by_team( $team, 'content_team' );
                    $content_area_cpt_ids[] = absint( $team->slug );
                }

                $content_area_cpt_ids = ! empty( $content_area_cpt_ids ) ? $content_area_cpt_ids : array( 0 );

                if ( 'clp_program_area_cpt' == $query->get( 'post_type' ) ) :

                    $query->set( 'post__in', $program_area_cpt_ids );

                else :

                    $query->set( 'tax_query', array(
                        array(
                            'taxonomy' => 'content_area',
                            'field'    => 'term_id',
                            'terms'    => $areas,
                            'operator' => 'IN',
                        ),
                    ) );

                endif;

            }
        }
    }
    add_action( 'pre_get_posts', 'wds_filter_content_areas', 10 );

    /**
     * Return a term ID for a content term based on that term's content team term.
     * @param  object $term The original term object.
     * @return int          The taxonomy term id.
     */
    function wds_get_term_id_by_team( $term ) {
        if ( is_array( $term ) && isset( $term['invalid_taxonomy'] ) || empty( $term ) ) {
            return;
        }

        $new_term = get_term_by( 'slug', $term->slug, 'content_area' );
        return $new_term->term_id;
    }

    /**
     * Break down the checks to user capabilities. If they can edit global content, show them things that they need to do their job.
     *
     * @return bool If a user is a member of a specific team, hide things.
     */
    function wds_is_site_content_editor() {

        // If the current user can edit global content, we don't need to filter.
        if ( current_user_can( 'edit_global_content' ) ) {
            return false;
        }

        // If the current user can only edit team content, we do need to filter.
        if ( current_user_can( 'edit_team_content' ) ) {
            return true;
        }

        return false;
    }

There’s a lot going on here, and there’s a number of helper functions that I’ve included so you can see more of what’s going on. I’m also showing line numbers in this snippet so I can refer to those specifically.  The pre_get_posts filter callback function starts on line 6 above.  When working with pre_get_posts we get a query object that we can manipulate and get information from, like on line 12 when we’re getting the post type. We then compare that against an array of post types (returned by wds_content_area_post_types()) to make sure we’re looking at one of the post types that’s going to be filtered for content creators and approvers. On line 17 we run a check and that function starts below on line 70–we’re making sure that the current user is a content creator–so, specifically, we’re checking against the edit_global_content capability, the higher level capability indicating that they are one of the default WordPress user roles.

If they don’t have that capability, we make sure that they have the edit_team_content capability, e.g. they are a content creator. If they lack that, too, we just return false and don’t filter anything. This will only allow users who are assigned to a specific team through the check. wp_get_terms_for_user is a function that’s added by WP User Roles. It returns an array of term objects for all of the User Taxonomy terms that a user is tagged with. We’re going to loop through these teams and save the term IDs to an array that we’ll pass to pre_get_posts later.

We’re also saving the slug to a variable array storing all the content team CPT post IDs. Huh? Saving a slug as an ID? Yes. We found in doing this process that using slugs to connect posts to taxonomies and taxonomies to taxonomies was not always reliable. Since none of the taxonomies we’re using are going to be on an archive page in our setup (what would be an archive page is actually the single post page for the content area CPT), we don’t care if the taxonomy slugs aren’t pretty. Saving the CPT post ID as the taxonomy term slugs ensures that we have a way to directly reference and relate not one but both of our taxonomies back to the CPT they relate to.

At this point, things get pretty simple. I’ve got an array of CPT IDs and if I’m looking at the content area CPTs right now, we’re going to filter down the list of posts that display to only those that relate to teams that I’m on. This ensures that members of that team could edit the landing page for that team (at least if they have edit_others_posts and/or edit_published_posts).

I’ve also got an array of term IDs that relate not to the content teams I’m on (although I got that, too) but to the content_area terms that those are connected to (the content taxonomy, not the user taxonomy). If I’m on any other type of edit screen, and we’re using the content_area taxonomy on that post type, my view will be filtered to only show me things belonging to the team(s) that I am on.

Now let’s see about saving our team’s term automagically to the post.

    /**
     * Adds Content Areas automagically to the post when it's saved.
     *
     * If a user creates any other post type, we want to find out what content
     * area they are in and add that content area to the post. This automatically
     * adds a the content area the user is associated with to the post.
     *
     * @param  int $post_id The post ID you're editing.
     */
    function wds_add_user_team_terms_to_post( $post_id ) {
        if ( wp_is_post_revision( $post_id ) )
            return;

        // Set Post taxonomy terms to sync with the users taxonomy terms.

        $user_terms = wp_get_terms_for_user( get_current_user_id(), 'clp_content_team' );

        // Get the normal taxonomy terms that are the same as the user taxonomy terms.
        foreach ( $user_terms as $term ) {
            $post_terms[] = $term->slug; // Add the slug to the array, when we add the normal taxon term below it will use the same slug.
        }

        // Actually associate the matched terms with the post.
        if ( isset( $post_terms ) ) {
            $__terms = wp_set_object_terms( $post_id, $post_terms, 'content_area' );
        }
    }
    add_action( 'save_post', array( $this, 'add_user_team_terms_to_post' ), 99 ); // User Content Team > Post Content Area.

This part is relatively straightforward. Once again we need to get the user taxonomy terms for the current user; this will give us an array of the teams the current user is on. Then we loop through those and start to populate a variable array of term slugs for each of those terms. The slugs will be the same for both taxonomies so the next step is to simply add those back in using wp_set_object_terms with a different taxonomy. One thing to keep in mind when using wp_set_object_terms–if an empty value is passed into it, and the final, $append parameter is not set to true (it defaults to false and is not used here), it will clear out any terms that were previously set on the post object. I spent two days of troubleshooting on this particular problem only to realize that the terms I was setting in one save_post hook were being overwritten with an empty value in a later save_post hook.

Let’s recap: I have users that I want to break down into teams. These teams are focussed around certain types of content. Each team has a landing page where all their posts for that subject or “content area” is published. If a user is on a team, everything they write should automatically be tagged with their team’s term. When they are in the WordPress admin, they will only see other content that’s tagged similarly with terms that correspond with their teams. There are global editors, too, and those users can view or edit any kind of content, regardless of what team it’s on.

There’s one final piece to this jigsaw puzzle and that’s publishing content to a specific page vs. publishing content globally. For this, we need to create custom post statuses.

Creating Custom Post Statuses

robot-soccer-falling

If you ask any developer how to create a custom post status, I guarantee you they will tell you “just use Edit Flow.” For a long, long time that was the only valid answer, it was a project maintained by some Automatticians and it did the thing that everybody needed it to do, at least with regard to custom statuses, and it did a pretty good job of it, too. However, it’s a project that’s been left along the wayside for a while and has not been maintained. As a result, when I went to actually use the custom post statuses module of Edit Flow, it just didn’t work. Not at all. It actually created a bunch of errors that I wasn’t eager to try to debug. Instead, I decided to try to actually create my own post statuses. Custom post statuses aren’t currently fully supported by WordPress, so some of this is a bit hacky and some of this may change later as support for custom post statuses is fleshed out more in Core.

For our purposes, we want to add a single custom post status, Team Published, which will be used to display content on the team-specific pages. This makes it easy to prevent content in this specific post status is not globally searchable and won’t come up in archive pages or on other parts of the site (e.g. parts of the site that are using normal WordPress queries to display published posts) which is what I want. In this case, all I need to do is change the query that handles the posts that display on my custom post type single page for content area CPTs to display all posts that are either published or team published in that particular content area–that’s already going to be a custom template, so we don’t need to go backward and try to filter out all the published stuff, we just choose which pages to explicitly include posts saved in our custom post status.

Adding a custom post status is very similar to adding a custom taxonomy or post type:

    /**
     * Add custom post statuses.
     * Currently there's just one custom post status -- Team Published.
     *
     * @link  https://codex.wordpress.org/Function_Reference/register_post_status
     * @since 0.2.0
     */
    function wds_add_post_statuses() {
        register_post_status( 'team-publish', array(
            'label'                     => __( 'Team Published', 'wds' ),
            'public'                    => true,
            'exclude_from_search'       => true,
            'show_in_admin_all_list'    => true,
            'show_in_admin_status_list' => true,
            'label_count'               => _n_noop( __( 'Team Published <span class="count">(%s)</span>', 'wds' ), __( 'Team Published <span class="count">(%s)</span>', 'clp' ) ),
        ) );
    }
    add_action( 'init', 'wds_add_post_statuses' );

We call register_post_status, we give our status a name, define whether it’s public and searchable and where we can find it and customize the labels that appear above the edit list in the admin if there are posts saved in our custom post status.

As I said, custom post statuses aren’t fully supported and, as such, this isn’t the end of our job. One thing we still need to do is actually display our custom post status. It can be created and saved but you can’t actually use it until you do something and since it’s not supported by WordPress, that means hacking the DOM after the edit post page has rendered using jQuery.

    /**
     * Alter the post status dropdown and publish button.
     *
     * @link   http://jamescollings.co.uk/blog/wordpress-create-custom-post-status/
     * @since  0.2.0
     * @return null
     */
    function wds_append_post_status_list() {
        global $post;

        if ( ! $post ) {
            return; // We need a post.
        }

        // Set up some variables.
        $published = __( 'Published' );
        $option    = __( 'Team Published', 'wds' );
        $complete  = ( 'team-publish' == $post->post_status ) ? ' selected="selected"' : '';
        $label     = ( 'team-publish' == $post->post_status ) ? '<span id=\"post-status-display\">&nbsp;' . esc_attr( $option ) . '</span>' : '';

        // Use javascript to append the Team Published option to the list of post statuses.
        echo '
        <script>
        jQuery(document).ready(function($){
            $("select#post_status").append("<option value=\"team-publish\" ' . esc_attr( $complete ) . '>' . esc_attr( $option ) . '</option>");
               $(".misc-pub-section label").append("' . $label . '");
        });
        </script>
        ';

        // If the current user can Team Publish posts, change the Publish button to say "Team Publish" instead.
        if ( current_user_can( 'team_publish_posts' ) ) {
            echo '
            <script>
            jQuery(document).ready(function($){
                var publishInput = $("input#publish");
                if ( "Publish" == publishInput.val() ) {
                    publishInput.val("' . esc_attr__( 'Team Publish', 'wds' ) . '");
                }
                $("select#post_status").change(function(){
                    $("a.save-post-status").click(function(){
                        publishInput.val("' . esc_attr__( 'Team Publish', 'wds' ) . '");
                    })
                });
            });
            </script>
            ';
        }

        // If the current user can actually publish, add a Published status to the dropdown of post statuses, too.
        if ( current_user_can( 'publish_team_posts' ) ) {

            // Only add Published to the dropdown if the post isn't already published. Prevents a duplicate Published from displaying in the list.
            if ( $post->post_status !== 'publish' ) {
                echo '
                <script>
                jQuery(document).ready(function($){
                    $("select#post_status").append("<option value=\"publish\">' . esc_attr( $published ) . '</option>");
                });
                </script>
                ';
            }
        }
    }
    add_filter( 'admin_footer', 'wds_append_post_status_list', 999 );

There’s a couple things this JavaScript is doing. First, we’re appending Team Publish to the statuses dropdown list. That’s the easy part. Next, we have a couple new capabilities we’re checking, which means they need to be added up where we map our custom capabilities to user roles. One is team_publish_posts — this capability is granted to users who can publish posts as “Team Published.” This applies to only content approver users–users who are on a team and get to “approve” the posts of other users on their team. The most those approvers can do is “team publish” a post. If they have that capability, their “Publish” button becomes a “Team Publish” button.

The second new capability is publish_team_posts. This sounds like I’m just rearranging words, I know, but the idea here is that if we have a post saved in “Team Published” status, it is a “team post,” and I want to publish it, e.g. literally turn it into a post saved in “published” status. This would be granted to WordPress editors and Administrators only. If I’m one of those users, I will also have a Publish option in my post statuses dropdown as well as a Team Published option.

That’s great, but guess what that blue button does even if we change the label on it to Team Publish? If you said that it actually publishes the post, you’d be right. So we need to jump in there and hijack that process to save the post in team published status instead. That requires a little bit more code.

    /**
     * Prevent the post from saving as Published if submitted by a user who shouldn't be able to publish.
     *
     * @since  0.2.0
     * @param  array $data    The submitted data.
     * @param  array $postarr Array of post data.
     * @link                  http://wordpress.stackexchange.com/a/113545
     * @return array          Updated post data.
     */
    function wds_prevent_post_change( $data, $postarr ) {

        if ( ! isset( $postarr['ID'] ) || ! $postarr['ID'] ) {
            return $data;
        }

        // If a user isn't able to team publish posts (only Content Approvers), do the normal stuff.
        if ( ! current_user_can( 'team_publish_posts' ) ) {
            return $data;
        }

        $old = get_post( $postarr['ID'] ); // The post before update.

        if (
            $old->post_status == 'auto-draft' ||
            $old->post_status !== 'trash' && // Without this post restoring from trash fail.
            'publish' === $data['post_status']
        ) {
            // Force the post to be set as team-publish.
            $data['post_status'] = 'team-publish';
        }

        return $data;
    }
    add_filter( 'wp_insert_post_data', 'wds_prevent_post_change', 20, 2 );

Here, we’re hooking into the wp_insert_post_data action and we’re checking for the team_publish_posts capability. Again, this only applies to one group of people, content approvers, so if you aren’t one of those, you just carry on with your normal work. If you are one of those users, and the post status being submitted is publish, hijack that status and change it to team-publish — our custom post status.

Now we’ve got a fully customized workflow for content creation teams that plays nice with default WordPress user roles, follows WordPress Core’s example in creating a hierarchical user role structure while still being able to pull of some fairly extensive and complex stuff dealing with users of particular roles and belonging to particular teams seeing (or not seeing) different types of content. This was all made possible by John James Jacoby’s WP User Groups plugin, which allows us to use a taxonomy to group users into teams.

If you’ve got some creative ways of managing users or teams, or if you’ve used any of JJJ’s Stuttter plugins, let us know about what you’re working on in the comments!

UPDATE: It came to our attention that there is a method in one of the code examples above that is not defined (hat-tip Aagha). For completeness, a method that should be suitable is provided here.

<?php
/**
 * Return an array of applicable post types.
 *
 * @param array $args The args to lookup post types with.
 * @return array
 */
function wds_content_area_post_types( $args = array() ) {
    $post_type_args = wp_parse_args( $args, array(
        'public' => true,
    ) );

    /**
     * Filter the post types from WordPress before we return them.
     *
     * @param array $post_types Array of WP post types return from get_post_types().
     * @return array
     */
    return apply_filters( 'wds_content_area_post_types_filter', get_post_types( $post_type_args ) );
}

Comments

8 thoughts on “Building Content Teams with WP User Groups and Custom User Roles

  1. Why did use the Wp user groups plugin, for user taxonomies?

    I ask as my one is older, has more features and the only proper library for user taxonomies in Wp…

    1. Honestly, just because I knew John had that plugin and it did what I needed it to. I was originally going to code the user teams functionality myself and use user meta before I found his plugin. I wouldn’t have even thought to use taxonomies to group users otherwise.

  2. Hi,

    This is really nice approach for handling WordPress permissions. Could you include full example source code somewhere too?

    1. Hi Mike —

      I’d love to but the actual scope of the plugin was way more complex than what I’ve posted here because it spanned 3 different user taxonomies (I only talk about one in the post) and a lot of the code was very specific to this particular client (which happened to be the part of the United States government). As such, I tried to abstract out as much as I could to communicate the idea without actually dropping the entire source code (which would have involved a lot of massaging before it was generic enough to be able to share).

  3. Thanks for this great post.

    I’m trying to find a method to allow access (read, edit, delete) to specific posts based on a post meta value. I have a role called “client.” I have a field call “clients” on my posts. I want to allow client X to edit posts where the value of clients includes X.

    Is there a portion of this posts’s examples that could be useful in this case? A method that involves adding capabilities? I’m having trouble getting my head around how this can be accomplished.

    1. Hi, Ryan. Interesting question. I checked with our engineering team and they expressed that your situation is a complex one that would require some research to figure out. They recommend that you post your question with details here: https://wordpress.stackexchange.com/. It’s a good place to get input from other WordPress developers who may be able to work with you to address your situation. Thanks for commenting and good luck!

Have a comment?

Your email address will not be published. Required fields are marked *

accessibilityadminaggregationanchorarrow-rightattach-iconbackupsblogbookmarksbuddypresscachingcalendarcaret-downcartunifiedcouponcrediblecredit-cardcustommigrationdesigndevecomfriendsgallerygoodgroupsgrowthhostingideasinternationalizationiphoneloyaltymailmaphealthmessagingArtboard 1migrationsmultiple-sourcesmultisitenewsnotificationsperformancephonepluginprofilesresearcharrowscalablescrapingsecuresecureseosharearrowarrowsourcestreamsupporttwitchunifiedupdatesvaultwebsitewordpress