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.

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:
- Register the PostGraphile plugin for bulk edit
- Create a message handler to process the operations (one-time setup)
- Register the handler in your messaging setup (one-time setup)
- Configure permissions for the mutation
Frontend:
- Run codegen to generate bulk edit configuration types
- Create a bulk edit configuration file
- Create a bulk edit component with field mappings
- 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
bulkEditMoviesAsyncmutation in your GraphQL schema - Input types for the
setargument (reusing the existingMoviePatchtype) - Input types for
relatedEntitiesToAddandrelatedEntitiesToRemovebased 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
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
UPDATEstatement to change column values - ADD_RELATED_ENTITY: Executes an
INSERTstatement to add relationships (gracefully handles duplicates) - REMOVE_RELATED_ENTITY: Executes a
DELETEstatement 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
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
BulkEditMoviesAsyncFormFieldsConfigfrom 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
defaultComponentMapfrom@axinom/mosaic-uifor standard fields - Uses
BulkEditFormFieldsConfigConverterto 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
MoviesBulkEditcomponent with the Explorer - Implements
saveDatacallback that:- Converts selected items or filters into a GraphQL filter
- Uses
generateBulkEditMutationhelper from@axinom/mosaic-uito 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:
- Registered the
BulkEditAsyncPluginFactoryto generate the mutation - Created a
BulkEditItemChangeHandlerusing the Mosaic Core helper (one-time) - Registered the handler in your messaging setup (one-time)
- Configured permissions for the mutation
Frontend setup:
- Configured codegen and ran it to generate
BulkEditMoviesAsyncFormFieldsConfig - Created
MoviesBulkEditConfig.tsto customize fields and labels - Created
MoviesBulkEdit.tsxto map fields to UI components - 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.
Related Documentation
- Bulk Edit - Technical Concept
- Create New Entity
- Extend Existing Entity