Bulk Edit
Overview
The Bulk Edit feature allows editors to perform the same operation on multiple items matching a filter in an Explorer. Instead of editing items one by one, editors can select multiple items of their desired explorer and update their properties, add relationships, or remove relationships in a single action.

The implementation consists of two main parts, backend components that auto-generate GraphQL mutations and process each operations asynchronously, and frontend components that render forms and generate mutations dynamically based on user input.
Mosaic Core provides libraries with helpers for both parts that handle most of the complexity:
Backend:
@axinom/mosaic-graphql-common- PostGraphile plugin and message handler helpers@axinom/mosaic-messages- Message types and messaging configuration
Frontend:
@axinom/mosaic-ui- Form rendering and mutation generation helpers@axinom/mosaic-graphql-codegen-plugins- Code generation for bulk edit configurations
Throughout this document, we'll be referring to the entities in the Media Service of the Mosaic Media Template as an example to describe the feature.
Backend

GraphQL Mutation Generation
The @axinom/mosaic-graphql-common exports the BulkEditAsyncPluginFactory which is a PostGraphile plugin that automatically
generates bulk edit mutations for any database table. It introspects the
database schema to discover:
- Table columns - Used to generate the
setargument (reuses the table's existingPatchinput type) - Foreign key relationships - Used to generate
relatedEntitiesToAddandrelatedEntitiesToRemovearguments
This automatic schema introspection ensures that the bulk edit mutation always stays in sync with your database structure. When you add a new column or relationship to a table, the bulk edit mutation automatically includes it after the next service restart.
Example mutation signature:
mutation {
bulkEditMoviesAsync(
filter: MovieFilter
set: MoviePatch
relatedEntitiesToAdd: BulkEditAsyncMovieAddInput
relatedEntitiesToRemove: BulkEditAsyncMovieRemoveInput
) {
filterMatchedIds
}
}
The plugin reuses existing GraphQL input types (like MoviePatch) for
consistency across the API. This means the bulk edit set argument accepts
exactly the same fields as a regular movie update mutation.
Excluding Relationships
Some relationships may not make sense for bulk operations. For example, internal metadata or audit logs should not be bulk editable. You can exclude specific relationships when registering the plugin:
BulkEditAsyncPluginFactory(
"movies",
"bulkEditMoviesAsync",
getLongLivedToken,
["movies_audit_log", "movies_snapshots"] // Excluded from bulk edit
);
Mutation Execution Flow
When a user executes a bulkEditMoviesAsync mutation, the following sequence
occurs:
- Token Generation
The plugin requests a long-lived token from the ID Service. This is necessary because bulk operations are processed asynchronously and may outlive the user's original JWT token. The long-lived token ensures that operations can complete even if the user's session expires.
- Filter Resolution
The plugin executes an internal GraphQL query to resolve the filter and retrieve all matching entity IDs:
query {
movies(filter: { studio: { equalTo: "Old Studio" } }) {
nodes {
id
}
}
}
This determines exactly which entities will be affected by the bulk operation.
- Message Generation
For each matched entity ID, the plugin generates PerformItemChangeCommand
messages. Each message represents a single operation on a single entity:
- SET_FIELD_VALUES - For field updates (e.g., change studio name)
- ADD_RELATED_ENTITY - For adding relationships (e.g., add genre to movie)
- REMOVE_RELATED_ENTITY - For removing relationships (e.g., remove trailer from movie)
If a user updates the studio AND adds two genres to 100 movies, the plugin generates:
- 100
SET_FIELD_VALUESmessages (one per movie for studio update) - 200
ADD_RELATED_ENTITYmessages (two per movie for genre additions)
- Direct Inbox Storage
Messages are stored directly to the service's inbox table without going through RabbitMQ. This is a performance optimization - since the service producing the messages is the same service that will consume them, there's no need for message broker overhead.
- Immediate Response
The mutation returns immediately with the list of matched entity IDs. The user doesn't wait for the operations to complete - they happen asynchronously in the background.
{
"filterMatchedIds": [1, 2, 3, 4, 5]
}
This response confirms that messages were created and stored, but provides no information about when the operations will complete or their final status.
Message Processing
The service continuously polls its inbox table for new messages. When a
PerformItemChangeCommand message is found, the registered message handler
processes it.
The actual database changes happen asynchronously with no exact completion time. Processing duration varies based on message queue depth and overall system load. Mosaic Core does not provide built-in progress tracking or status reporting at the moment, and services must implement their own tracking mechanisms if this functionality is required.
Message Structure
From @axinom/mosaic-messages:
export interface PerformItemChangeCommand {
table_name: string; // Target database table
action: BulkActionType; // Type of operation
stringified_condition: string; // Stringified key-value object for WHERE clause
stringified_payload: string; // Stringified key-value object for operation data
}
export type BulkActionType =
| "SET_FIELD_VALUES"
| "ADD_RELATED_ENTITY"
| "REMOVE_RELATED_ENTITY";
Handler Behavior
Mosaic Core provides a handlePerformItemChangeCommand helper from
@axinom/mosaic-graphql-common. This helper is table-agnostic - it works
for any entity type (movies, episodes, seasons, etc.). Services only need to set
up the message handler once, and it will process bulk edit operations for all
entities.
The helper executes SQL based on the action type:
SET_FIELD_VALUES→UPDATE table SET field = value WHERE id = ?ADD_RELATED_ENTITY→INSERT INTO relation_table (entity_id, related_id) VALUES (?, ?)(ignores duplicate key errors)REMOVE_RELATED_ENTITY→DELETE FROM relation_table WHERE entity_id = ? AND related_id = ?
The transactional inbox pattern handles retries for transient failures automatically. If a database operation fails temporarily, the message remains in the inbox and will be retried.
Customization
Custom Handler Logic
For most use cases, the standard handlePerformItemChangeCommand helper is
sufficient. However, services can add custom logic for specific scenarios.
Example: Upsert behavior for unique constraints
If movies can only have one cover image, you might want to delete the existing cover before adding a new one:
async handleMessage(
message: TypedTransactionalMessage<PerformItemChangeCommand>,
envOwnerClient: ClientBase,
context: GuardedContext,
): Promise<void> {
const { payload } = message;
// Custom handling for cover images
if (
payload.action === 'ADD_RELATED_ENTITY' &&
payload.table_name === 'movies_images'
) {
const data = JSON.parse(payload.stringified_payload);
// Only apply upsert logic for COVER type
if (data.image_type === 'COVER') {
// Delete existing cover first
await envOwnerClient.query(
`DELETE FROM movies_images WHERE movie_id = $1 AND image_type = 'COVER'`,
[data.movie_id]
);
// Then insert the new cover
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 default handler
}
}
// For all other cases, use the default helper
await handlePerformItemChangeCommand(payload, envOwnerClient);
}
Frontend
Code Generation
Before creating any bulk edit UI, developers must run GraphQL codegen to auto-generate configuration objects from the GraphQL schema.
Codegen Plugin
Mosaic Core provides a generate-bulk-edit-ui-config plugin from
@axinom/mosaic-graphql-codegen-plugins that runs as part of the standard
GraphQL codegen workflow. The plugin:
- Discovers all
bulkEdit*Asyncmutations in the GraphQL schema - Introspects their arguments (
set,relatedEntitiesToAdd,relatedEntitiesToRemove) - Generates
BulkEdit*FormFieldsConfigconstants with all available fields and their types
Generated output example:
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'
},
synopsis: {
type: 'String',
label: 'Synopsis',
originalFieldName: 'synopsis',
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 fields
}
};
This auto-generated configuration includes all possible fields. Labels are automatically formatted using title case (e.g., "Movies Movie Genres (Add)").
Developers run yarn codegen after backend changes to regenerate these
configurations. This keeps the generated graphql.tsx in sync with the GraphQL API.
Form Configuration
The auto-generated BulkEditMoviesAsyncFormFieldsConfig includes every possible
field, but not all fields should be exposed in the UI. Developers create a
custom configuration file to:
- Improve labels for better user experience
- Map fields to custom component types (e.g., genre selectors, image pickers)
- Remove fields that don't make sense for bulk operations
- Add custom fields with specific behavior
Example customization:
import { BulkEditMoviesAsyncFormFieldsConfig } from "../../../generated/graphql";
import { labelMapper, typeMapper } from "../../../Util/BulkEdit";
export const MoviesBulkEditConfig = (() => {
const fields = { ...BulkEditMoviesAsyncFormFieldsConfig.fields };
// Improve labels
labelMapper(fields, {
moviesMovieGenresAdd: "Genres (Add)",
moviesMovieGenresRemove: "Genres (Remove)",
moviesCastsAdd: "Cast (Add)",
moviesTrailersAdd: "Trailer (Add)",
});
// Map to custom components
typeMapper(fields, {
moviesMovieGenresAdd: "MovieGenreSelection",
moviesTrailersAdd: "VideoSelection",
});
// Remove fields
delete fields["moviesLicensesAdd"];
delete fields["moviesSnapshotsAdd"];
// Add custom fields
fields["moviesCoverImagesAdd"] = {
type: "CoverImageSelection",
label: "Cover Image (Add)",
originalFieldName: "moviesImages",
action: BulkEditMoviesAsyncFormFieldsConfig.keys.add,
};
return {
...BulkEditMoviesAsyncFormFieldsConfig,
fields,
};
})();
This configuration object serves as the foundation for rendering the bulk edit form.
Component Mapping
The BulkEditFormFieldsConfigConverter from @axinom/mosaic-ui converts the
configuration into a rendered React form. Developers provide a componentMap
that maps field types to actual React components.
Example:
import {
BulkEditFormFieldsConfigConverter,
defaultComponentMap,
} from "@axinom/mosaic-ui";
import { MovieGenreSelectField } from "./MovieGenreSelectField";
import { MoviesBulkEditConfig } from "./MoviesBulkEditConfig";
export const MoviesBulkEdit: React.FC = () => {
const componentMap = {
...defaultComponentMap, // Standard inputs (text, number, date, etc.)
MovieGenreSelection: MovieGenreSelectField, // Custom genre selector
CoverImageSelection: ImageSelectField, // Custom image selector
VideoSelection: VideoSelectField, // Custom video selector
};
return BulkEditFormFieldsConfigConverter(
MoviesBulkEditConfig.fields,
componentMap
);
};
The defaultComponentMap from @axinom/mosaic-ui provides standard form inputs
for common field types (strings, numbers, dates, booleans, etc.). Custom
components override specific field types for domain-specific selection UI.
Example custom component:
// Genre selector with TagsField
const const MovieGenreSelectField: React.FC<
TagsProps<{ title: string; id: string }>
> = (props) => {
const { data } = useMovieGenresQuery();
const genreOptions = data?.movieGenres?.nodes || [];
return (
<TagsField
{...props}
tagsOptions={genreOptions}
displayKey="title"
valueKey="id"
value={
(value as unknown as { movieGenresId: string }[])?.map((val) =>
String(val.movieGenresId),
) ?? []
}
onChange={(event) => {
const value = (event.currentTarget.value as unknown as string[]).map(
(id) => ({
movieGenresId: Number(id),
}),
);
onChange &&
onChange({
...event,
target: {
...event.target,
value: value as unknown as string,
},
});
}}
/>
);
};
After adjusting everything the Bulk Edit Properties form could look like this.
.
Mutation Generation
When the user submits the bulk edit form, the frontend must construct a GraphQL
mutation with only the fields that the user filled in. Mosaic Core provides a
generateBulkEditMutation helper from @axinom/mosaic-ui that dynamically
builds the mutation.
Example:
const mutation = generateBulkEditMutation(
MoviesBulkEditConfig,
formData, // { studio: "New Studio", moviesMovieGenresAdd: [{ genresId: 5 }] }
filter // { studio: { equalTo: "Old Studio" } }
);
// Generates:
// mutation {
// bulkEditMoviesAsync(
// filter: { studio: { equalTo: "Old Studio" } }
// set: { studio: "New Studio" }
// relatedEntitiesToAdd: { moviesMovieGenres: [{ genresId: 5 }] }
// ) {
// filterMatchedIds
// }
// }
The helper inspects the formData to determine which arguments (set,
relatedEntitiesToAdd, relatedEntitiesToRemove) to include in the mutation.
Empty fields are omitted.
Explorer Integration
The bulk edit UI is registered with an Explorer component using the
bulkEditRegistration prop. This integration connects the form, mutation
generation, and GraphQL execution.
Example:
import { generateBulkEditMutation } from "@axinom/mosaic-ui";
import { gql } from "graphql-tag";
import { MoviesBulkEdit } from "./BulkEdit/MoviesBulkEdit";
import { MoviesBulkEditConfig } from "./BulkEdit/MoviesBulkEditConfig";
import { MovieExplorer } from "./MovieExplorer";
export const Movies: React.FC = () => {
const { transformFilters } = useMoviesFilters();
return (
<MovieExplorer
title="Movies"
// ... other props
bulkEditRegistration={{
component: <MoviesBulkEdit />,
saveData: async (data, items) => {
// Convert Explorer selection to GraphQL filter
const filter =
items.mode === "SINGLE_ITEMS"
? { id: { in: items.items.map((i) => i.id) } }
: transformFilters(items.filters);
// Generate mutation dynamically
const mutation = generateBulkEditMutation(
MoviesBulkEditConfig,
data,
filter
);
// Execute mutation
await client.mutate({
mutation: gql`
${mutation}
`,
});
},
}}
/>
);
};
The bulkEditRegistration prop has two parts:
config- The bulk edit form configuration object (use this if the generated config can be used directly without any customization)component- The bulk edit form component to render (this will override the configuration passed throughconfigprop)saveData- A callback that executes when the user submits the form
The saveData callback receives:
data- The form values entered by the useritems- Information about which entities are selected in the Explorer
The Explorer supports two selection modes:
SINGLE_ITEMS- User selected specific entities individually (createsid: { in: [...] }filter)SELECT_ALL- User clicked "Select All" with active filters (uses the Explorer's current filter)
Customization
Custom Field Components
Services can create custom field components for domain-specific selection UI. These components should integrate with the form's state management and validation.
Example: Image selector when an entity has related images
import { ImageSelectFieldProps } from "@axinom/mosaic-managed-workflow-integration";
import { BulkEditEnumType } from "@axinom/mosaic-ui";
import React, { useContext } from "react";
import { ExtensionsContext } from "../../externals";
const getBulkEditImageSelectField: (
type: string,
scope: string,
maxItems?: number
) => React.FC<ImageSelectFieldProps> = (type, scope, maxItems) => {
const Component: React.FC<ImageSelectFieldProps> = (props) => {
const { ImageSelectField } = useContext(ExtensionsContext);
return (
<ImageSelectField
{...props}
value={props.value ? props.value.map((item) => item.imageId) : []}
onChange={(event) => {
const value = (
event as { currentTarget: { value: string[] } }
).currentTarget.value.map((id) => ({
imageId: id,
imageType: new BulkEditEnumType(type),
}));
props.onChange({
currentTarget: {
name: props.name,
value,
},
});
}}
imageType={`${scope}_${type.toLowerCase()}`}
maxItems={maxItems}
/>
);
};
Component.displayName = `ImageSelectField_${type}`;
return Component;
};
Field Filtering
Not all auto-generated fields should be exposed in the bulk edit UI. Fields can be removed or customized in the configuration:
// Remove internal fields
delete fields["moviesLicensesAdd"];
delete fields["moviesSnapshotsRemove"];
delete fields["released"]; // Complex field requiring special handling
// Add custom fields with specific behavior
fields["moviesCoverImagesAdd"] = {
type: "CoverImageSelection",
label: "Cover Image (Add)",
originalFieldName: "moviesImages", // Maps to moviesImages in the mutation
action: BulkEditMoviesAsyncFormFieldsConfig.keys.add,
};
Custom fields can map to the same database field as auto-generated fields but
with different UI behavior. In the example above, moviesCoverImagesAdd maps to
moviesImages but renders a specialized image selector that filters to cover
images only.
User Workflow
From the user's perspective, the bulk edit workflow is:
- Select items in the Explorer (either individual selection or "Select All" with filters)
- Click bulk edit action in the Explorer header
- Form displays with available fields based on the configuration
- Fill in desired changes (e.g., update studio, add genres)
- Click apply
- Mutation executes and returns matched IDs immediately
- Backend processes operations asynchronously
- User sees success a notification that the processing has started
The user doesn't wait for the operations to complete - they can continue working immediately while the backend processes the changes in the background.
Related Documentation
- Enable Bulk Edit for an Entity - Step-by-step implementation guide