Skip to main content

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.

Bulk-Edit Usage

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

Bulk Edit Workflow

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 set argument (reuses the table's existing Patch input type)
  • Foreign key relationships - Used to generate relatedEntitiesToAdd and relatedEntitiesToRemove arguments

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:

  1. 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.

  1. 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.

  1. 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_VALUES messages (one per movie for studio update)
  • 200 ADD_RELATED_ENTITY messages (two per movie for genre additions)
  1. 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.

  1. 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_VALUESUPDATE table SET field = value WHERE id = ?
  • ADD_RELATED_ENTITYINSERT INTO relation_table (entity_id, related_id) VALUES (?, ?) (ignores duplicate key errors)
  • REMOVE_RELATED_ENTITYDELETE 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:

  1. Discovers all bulkEdit*Async mutations in the GraphQL schema
  2. Introspects their arguments (set, relatedEntitiesToAdd, relatedEntitiesToRemove)
  3. Generates BulkEdit*FormFieldsConfig constants 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. bulk-edit-form.

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 through config prop)
  • saveData - A callback that executes when the user submits the form

The saveData callback receives:

  • data - The form values entered by the user
  • items - Information about which entities are selected in the Explorer

The Explorer supports two selection modes:

  1. SINGLE_ITEMS - User selected specific entities individually (creates id: { in: [...] } filter)
  2. 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:

  1. Select items in the Explorer (either individual selection or "Select All" with filters)
  2. Click bulk edit action in the Explorer header
  3. Form displays with available fields based on the configuration
  4. Fill in desired changes (e.g., update studio, add genres)
  5. Click apply
  6. Mutation executes and returns matched IDs immediately
  7. Backend processes operations asynchronously
  8. 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.