Skip to main content

Enable Bulk Edit for an Entity

Overview

In this guide, you learn how to enable the Bulk Edit feature for an entity in your service. This feature allows editors to perform the same operation on multiple entities that match a filter, instead of editing them one by one.

For example, editors can select all movies from a specific studio and update their production year, or add the same genre to multiple episodes at once.

Bulk-Edit Usage

See also: Bulk Edit

Prerequisites

Make sure that your service:

  • Uses the transactional inbox pattern for async message handling
  • Has a PostGraphile-based GraphQL API

Goal

By the end of this guide, you will have enabled a bulkEditMoviesAsync mutation that allows editors to perform batch operations like:

  • Update the studio name or synopsis for all movies matching a filter
  • Add cast members, genres, or licenses to multiple movies at once
  • Remove trailers, images, or production countries from multiple movies

The guide shows you how to add the necessary backend and frontend components. The platform handles most of the complexity through helper libraries and code generation.

On a high level, the guide consists of:

Backend:

  1. Register the PostGraphile plugin for bulk edit
  2. Create a message handler to process the operations (one-time setup)
  3. Register the handler in your messaging setup (one-time setup)
  4. Configure permissions for the mutation

Frontend:

  1. Run codegen to generate bulk edit configuration types
  2. Create a bulk edit configuration file
  3. Create a bulk edit component with field mappings
  4. Register the bulk edit in the Explorer

Backend Implementation

1. Register the PostGraphile Plugin

The BulkEditAsyncPluginFactory automatically generates the bulk edit mutation for your table by introspecting your database schema. It discovers all columns and foreign key relationships, creating the appropriate GraphQL input types.

Navigate to your PostGraphile options file (e.g., src/graphql/postgraphile-options.ts in your service) and import BulkEditAsyncPluginFactory from @axinom/mosaic-graphql-common:

import { BulkEditAsyncPluginFactory } from "@axinom/mosaic-graphql-common";
import { getLongLivedToken } from "../common";

export const buildPostgraphileOptions = (
config: Config,
ownerPool: OwnerPgPool,
storeOutboxMessage: StoreOutboxMessage,
storeInboxMessage: StoreInboxMessage
// ... other parameters
): PostGraphileOptions<Request, Response> => {
return (
new PostgraphileOptionsBuilder()
.setDefaultSettings(config.isDev, config.graphqlGuiEnabled)
// ... other configuration
.addPlugins(
// ... other plugins
BulkEditAsyncPluginFactory(
"movies", // Database table name
"bulkEditMoviesAsync", // GraphQL mutation name
getLongLivedToken // helper callback that will generate a long-lived token
)
)
.build()
);
};

What this does:

The plugin will introspect the movies table and automatically generate:

  • A bulkEditMoviesAsync mutation in your GraphQL schema
  • Input types for the set argument (reusing the existing MoviePatch type)
  • Input types for relatedEntitiesToAdd and relatedEntitiesToRemove based on foreign key relationships

The mutation signature will look like:

mutation BulkEditMovies(
$filter: MovieFilter
$set: MoviePatch
$relatedEntitiesToAdd: BulkEditAsyncMovieAddInput
$relatedEntitiesToRemove: BulkEditAsyncMovieRemoveInput
) {
bulkEditMoviesAsync(
filter: $filter
set: $set
relatedEntitiesToAdd: $relatedEntitiesToAdd
relatedEntitiesToRemove: $relatedEntitiesToRemove
) {
filterMatchedIds
}
}

2. Create the Message Handler

note

One-Time setup for the service

This is a one-time setup for your service. The message handler is generic and will process bulk edit operations for all entities (movies, episodes, seasons, etc.) of the service's database.

The message handler processes the bulk operations asynchronously. When editors execute a bulk edit, messages are stored directly to your service's inbox table, and the handler processes them one by one.

Mosaic Core provides a helper function handlePerformItemChangeCommand (from @axinom/mosaic-graphql-common) that handles the standard operations (UPDATE, INSERT, DELETE). For most cases, you can simply call this helper.

Create a message handler file in your service (e.g., src/domains/common/handlers/bulk-edit-item-change-handler.ts):

import { handlePerformItemChangeCommand } from "@axinom/mosaic-graphql-common";
import { GuardedContext } from "@axinom/mosaic-id-guard";
import {
CommonServiceMessagingSettings,
PerformItemChangeCommand,
} from "@axinom/mosaic-messages";
import { Logger } from "@axinom/mosaic-service-common";
import { TypedTransactionalMessage } from "@axinom/mosaic-transactional-inbox-outbox";
import { ClientBase } from "pg";
import { Config } from "../../../common";
import { MediaGuardedTransactionalInboxMessageHandler } from "../../../messaging";
import { PermissionKey } from "../../permission-definition";

export const bulkEditPermissions: PermissionKey[] = ["ADMIN", "MOVIES_EDIT"];

export class BulkEditItemChangeHandler extends MediaGuardedTransactionalInboxMessageHandler<PerformItemChangeCommand> {
constructor(config: Config) {
super(
CommonServiceMessagingSettings.GetPerformItemChangeSettings(
config.serviceId
),
bulkEditPermissions,
new Logger({ config, context: BulkEditItemChangeHandler.name }),
config
);
}

async handleMessage(
message: TypedTransactionalMessage<PerformItemChangeCommand>,
envOwnerClient: ClientBase,
_context: GuardedContext
): Promise<void> {
// Use the Mosaic Core helper for standard operations
await handlePerformItemChangeCommand(message.payload, envOwnerClient);
}
}

What the helper does:

The handlePerformItemChangeCommand helper handles three types of operations:

  • SET_FIELD_VALUES: Executes an UPDATE statement to change column values
  • ADD_RELATED_ENTITY: Executes an INSERT statement to add relationships (gracefully handles duplicates)
  • REMOVE_RELATED_ENTITY: Executes a DELETE statement to remove relationships

For most use cases, this is all you need. If you have special requirements (like upsert logic for unique constraints), you can intercept messages before calling the helper - see the "Advanced Customization" section below.

3. Register the Message Handler

note

One-Time setup for the service

Now you need to register your handler in the messaging registry so it starts consuming messages from the inbox.

Navigate to your messaging registry file (e.g., src/messaging/register-messaging.ts) and add your handler:

import { BulkEditItemChangeHandler } from "../domains/common/handlers";

export const registerMessaging = (
storeOutboxMessage: StoreOutboxMessage,
storeInboxMessage: StoreInboxMessage,
config: Config
) => {
return (
new TransactionalInboxOutboxBuilder(
"media_service",
config.pgReplicationConfig
)
// ... other handlers
.addInboxHandler(new BulkEditItemChangeHandler(config))
.build()
);
};

4. Configure Permissions

Finally, you need to assign the bulk edit mutation to the appropriate permissions. Editors will only be able to use the mutation if they have one of the configured permissions.

Navigate to your permission definition file (e.g., src/domains/permission-definition.ts):

import { Mutations as M } from "../generated/graphql/operations";

export const permissions: Permission<PermissionKey>[] = [
{
key: "ADMIN",
title: "Admin",
gqlOperations: [
// ... other operations
M.bulkEditMoviesAsync,
],
},
{
key: "MOVIES_EDIT",
title: "Movies: Edit",
gqlOperations: [
// ... other operations
M.bulkEditMoviesAsync,
],
},
// ... other permissions
];

Once you save the file and restart your service, the types will be regenerated and the mutation will be available in your GraphQL API.


Frontend Implementation

The frontend integration starts with running codegen to auto-generate the base configuration from your GraphQL schema. Then you manually create files to customize which fields are displayed and how they behave.

Mosaic Core provides helpers from @axinom/mosaic-ui to generate mutations and render forms.

1. Configure and Run GraphQL Codegen

Mosaic Core provides a GraphQL codegen plugin that introspects your GraphQL schema and automatically generates bulk edit configuration objects for all bulkEdit*Async mutations.

Add the Plugin to codegen.yml

Navigate to your frontend's codegen.yml file (e.g., in your workflows package) and ensure the bulk edit plugin (@axinom/mosaic-graphql-codegen-plugins/generate-bulk-edit-ui-config) is included:

schema: "${MEDIA_MANAGEMENT_HTTP_PROTOCOL}://${MEDIA_MANAGEMENT_HOST}/graphql"
documents: "src/**/*.graphql"
generates:
src/generated/graphql.tsx:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"
- "@axinom/mosaic-graphql-codegen-plugins/generate-bulk-edit-ui-config"
config:
withHOC: false
withComponent: false
withHooks: true

Install the Codegen Plugin

Ensure @axinom/mosaic-graphql-codegen-plugins is in your package.json devDependencies.

Run Codegen

Once your backend is running (with the bulkEditMoviesAsync mutation available in the GraphQL API):

yarn workspace media-workflows codegen

What this generates:

The plugin adds a BulkEditMoviesAsyncFormFieldsConfig constant to src/generated/graphql.tsx:

export const BulkEditMoviesAsyncFormFieldsConfig = {
mutation: 'bulkEditMoviesAsync',
keys: {
add: 'relatedEntitiesToAdd',
remove: 'relatedEntitiesToRemove',
set: 'set',
filter: 'filter'
},
fields: {
// SET fields (from MoviePatch type)
studio: {
type: 'String',
label: 'Studio',
originalFieldName: 'studio',
action: 'set'
},
// ADD fields (from relatedEntitiesToAdd argument)
moviesMovieGenresAdd: {
type: [...], // Array of nested field types
label: 'Movies Movie Genres (Add)',
originalFieldName: 'moviesMovieGenres',
action: 'relatedEntitiesToAdd'
},
// REMOVE fields (from relatedEntitiesToRemove argument)
moviesMovieGenresRemove: {
type: [...],
label: 'Movies Movie Genres (Remove)',
originalFieldName: 'moviesMovieGenres',
action: 'relatedEntitiesToRemove'
}
// ... all other discovered fields
}
};

This auto-generated config includes all fields discovered from the GraphQL schema. The generated labels are automatically formatted (e.g., "Movies Movie Genres (Add)"), but you may want to customize them for better UX.

2. Create Custom Bulk Edit Configuration

Now create a configuration file that customizes the auto-generated config - updating labels, changing field types for custom components, and removing fields that shouldn't be bulk editable.

Create a configuration file for your entity (e.g., workflows/src/Stations/Movies/MoviesExplorer/BulkEdit/MoviesBulkEditConfig.ts file):

import { BulkEditMoviesAsyncFormFieldsConfig } from "../../../../generated/graphql";
import { labelMapper, typeMapper } from "../../../../Util/BulkEdit";

export const MoviesBulkEditConfig = (() => {
const fields: Partial<typeof BulkEditMoviesAsyncFormFieldsConfig.fields> = {
...BulkEditMoviesAsyncFormFieldsConfig.fields,
};

// Customize field labels for better UX
labelMapper(fields, {
moviesMovieGenresAdd: "Genres (Add)",
moviesMovieGenresRemove: "Genres (Remove)",
moviesCastsAdd: "Cast (Add)",
moviesCastsRemove: "Cast (Remove)",
moviesProductionCountriesAdd: "Production Country (Add)",
moviesProductionCountriesRemove: "Production Country (Remove)",
moviesTrailersAdd: "Trailer (Add)",
moviesTrailersRemove: "Trailer (Remove)",
mainVideoId: "Main Video",
moviesTagsAdd: "Tags (Add)",
moviesTagsRemove: "Tags (Remove)",
});

// Map fields to custom component types
typeMapper(fields, {
moviesTrailersAdd: "VideoSelection",
moviesTrailersRemove: "VideoSelection",
moviesMovieGenresAdd: "MovieGenreSelection",
moviesMovieGenresRemove: "MovieGenreSelection",
});

// Remove fields that shouldn't be bulk editable
delete fields["collectionRelationsAdd"];
delete fields["collectionRelationsRemove"];
delete fields["moviesLicensesAdd"];
delete fields["moviesLicensesRemove"];
delete fields["moviesSnapshotsAdd"];
delete fields["moviesSnapshotsRemove"];
delete fields["moviesImagesAdd"];
delete fields["moviesImagesRemove"];
delete fields["released"];

// Add custom image fields with specific image types
fields["moviesCoverImagesAdd"] = {
type: "CoverImageSelection",
label: "Cover Image (Add)",
originalFieldName: "moviesImages",
action: BulkEditMoviesAsyncFormFieldsConfig.keys.add,
};

fields["moviesCoverImagesRemove"] = {
type: "CoverImageSelection",
label: "Cover Image (Remove)",
originalFieldName: "moviesImages",
action: BulkEditMoviesAsyncFormFieldsConfig.keys.remove,
};

return {
...BulkEditMoviesAsyncFormFieldsConfig,
fields,
};
})();

What this does:

  • Starts with auto-generated BulkEditMoviesAsyncFormFieldsConfig from GraphQL schema
  • Customizes labels for better user experience
  • Maps fields to custom component types for specialized UI controls
  • Removes fields that don't make sense for bulk operations
  • Adds custom fields with specific behavior (e.g., cover images with image type filtering)

3. Create Bulk Edit Component

Create a component that maps field types to actual React components (e.g., workflows/src/Stations/Movies/MoviesExplorer/BulkEdit/MoviesBulkEdit.tsx file):

import {
BulkEditFormFieldsConfigConverter,
defaultComponentMap,
} from "@axinom/mosaic-ui";
import React from "react";
import {
MainVideoSelectionField,
getBulkEditImageSelectField,
getVideoSelectField,
} from "../../../../Util/BulkEdit";
import { MovieImageType } from "../../../../generated/graphql";
import { MovieGenreSelectField } from "./MovieGenreSelectField";
import { MoviesBulkEditConfig } from "./MoviesBulkEditConfig";

export const MoviesBulkEdit: React.FC = () => {
const componentMap = {
...defaultComponentMap,
CoverImageSelection: getBulkEditImageSelectField(
MovieImageType.Cover,
"movie",
1
),
TeaserImageSelection: getBulkEditImageSelectField(
MovieImageType.Teaser,
"movie",
1
),
VideoSelection: getVideoSelectField("TRAILER"),
UUID: MainVideoSelectionField,
MovieGenreSelection: MovieGenreSelectField,
};

const fields = MoviesBulkEditConfig.fields;

return BulkEditFormFieldsConfigConverter(
Object.entries(fields)
.sort(([, a], [, b]) => (a.label || '').localeCompare(b.label || ''))
.reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {}),
componentMap,
);
};

What this does:

  • Maps custom field types to React components (e.g., CoverImageSelection → image selector component)
  • Uses defaultComponentMap from @axinom/mosaic-ui for standard fields
  • Uses BulkEditFormFieldsConfigConverter to render the form based on configuration

4. Register Bulk Edit in Explorer

Update your Explorer component to include the bulk edit registration (e.g., workflows/src/Stations/Movies/MoviesExplorer/Movies.tsx file):

import { generateBulkEditMutation } from "@axinom/mosaic-ui";
import { gql } from "graphql-tag";
import React from "react";
import { client } from "../../../apolloClient";
import { MovieExplorer } from "../MovieExplorerBase/MovieExplorer";
import { useMoviesFilters } from "../MovieExplorerBase/MovieExplorer.filters";
import { MoviesBulkEdit } from "./BulkEdit/MoviesBulkEdit";
import { MoviesBulkEditConfig } from "./BulkEdit/MoviesBulkEditConfig";

export const Movies: React.FC = () => {
const { transformFilters } = useMoviesFilters();

return (
<MovieExplorer
title="Movies"
stationKey="MoviesExplorer"
// ... other props
bulkEditRegistration={{
component: <MoviesBulkEdit />,
saveData: async (data, items) => {
let filter = undefined as Record<string, unknown> | undefined;

if (
items.mode === "SINGLE_ITEMS" &&
items.items &&
items.items.length > 0
) {
filter = { id: { in: items.items.map((item) => item.id) } };
}

if (items.mode === "SELECT_ALL") {
filter = transformFilters(items.filters);
}

const mutation = generateBulkEditMutation(
MoviesBulkEditConfig,
data,
filter
);

await client.mutate({
mutation: gql`
${mutation}
`,
});
},
}}
/>
);
};

What this does:

  • Registers the MoviesBulkEdit component with the Explorer
  • Implements saveData callback that:
    • Converts selected items or filters into a GraphQL filter
    • Uses generateBulkEditMutation helper from @axinom/mosaic-ui to build the mutation
    • Executes the mutation against the GraphQL API

The generateBulkEditMutation helper automatically constructs the correct bulkEditMoviesAsync mutation based on which fields the user filled in the form.


Testing

Testing the Mutation

You can test the bulk edit mutation using GraphiQL at http://localhost:10200/graphiql.

First, generate an access token:

yarn workspace @axinom/mosaic-media-service util:token

Add it to GraphiQL's authorization header:

{
"Authorization": "Bearer <your_token>"
}

Now you can try the mutation:

Example 1: Update studio name for multiple movies

mutation BulkUpdateStudio {
bulkEditMoviesAsync(
filter: { studio: { equalTo: "Old Studio" } }
set: { studio: "New Studio" }
) {
filterMatchedIds
}
}

Example 2: Add genres to multiple movies

mutation BulkAddGenres {
bulkEditMoviesAsync(
filter: { title: { includesInsensitive: "action" } }
relatedEntitiesToAdd: {
moviesGenres: [
{ genresId: 5 } # Action genre
{ genresId: 8 } # Thriller genre
]
}
) {
filterMatchedIds
}
}

Example 3: Replace cover images for specific movies

mutation BulkReplaceCovers {
bulkEditMoviesAsync(
filter: { id: { in: [1, 2, 3, 4, 5] } }
relatedEntitiesToRemove: {
moviesImages: [
{ imageId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", imageType: "COVER" }
]
}
relatedEntitiesToAdd: {
moviesImages: [
{ imageId: "f6e5d4c3-b2a1-0987-6543-210fedcba098", imageType: "COVER" }
]
}
) {
filterMatchedIds
}
}

The mutation returns immediately with the IDs of matched entities. The actual operations happen asynchronously via message processing. Check your service logs to see the operations being executed.

Advanced Customization

Custom Operation Logic

For complex scenarios, you can add custom logic in your message handler before calling the helper. This is useful when you need upsert semantics or special validation.

For example, if movies can only have one image per type, you might want to delete the existing image before inserting a new one:

async handleMessage(
message: TypedTransactionalMessage<PerformItemChangeCommand>,
envOwnerClient: ClientBase,
_context: GuardedContext,
): Promise<void> {
const { payload } = message;

// Custom handling for image assignments
if (
payload.action === 'ADD_RELATED_ENTITY' &&
payload.table_name === 'movies_images'
) {
const data = JSON.parse(payload.stringified_payload);

// Delete existing image of the same type first
await envOwnerClient.query(
`DELETE FROM movies_images WHERE movie_id = $1 AND image_type = $2`,
[data.movie_id, data.image_type]
);

// Then insert the new image
await envOwnerClient.query(
`INSERT INTO movies_images (movie_id, image_id, image_type) VALUES ($1, $2, $3)`,
[data.movie_id, data.image_id, data.image_type]
);

return; // Done, skip the helper
}

// For all other cases, use the default helper
await handlePerformItemChangeCommand(payload, envOwnerClient);
}

Excluding Relationships

Some relationships might not make sense for bulk operations (ex: internal metadata relations, publish snapshot relations, etc.). You can exclude them when registering the plugin:

BulkEditAsyncPluginFactory(
"movies",
"bulkEditMoviesAsync",
getLongLivedToken,
["movies_audit_log", "movies_snapshots"] // These won't appear in bulk edit inputs
);

Summary

You have successfully enabled bulk edit for the Movies entity.

Backend setup:

  1. Registered the BulkEditAsyncPluginFactory to generate the mutation
  2. Created a BulkEditItemChangeHandler using the Mosaic Core helper (one-time)
  3. Registered the handler in your messaging setup (one-time)
  4. Configured permissions for the mutation

Frontend setup:

  1. Configured codegen and ran it to generate BulkEditMoviesAsyncFormFieldsConfig
  2. Created MoviesBulkEditConfig.ts to customize fields and labels
  3. Created MoviesBulkEdit.tsx to map fields to UI components
  4. Registered the bulk edit in the Explorer with bulkEditRegistration

You can repeat the backend Step 1 and all frontend steps for other entities (TV Shows, Seasons, Episodes, etc.). The same message handler will process operations for all entity types.