@scaleflex/bulk-edit
v0.8.1
Published
Standalone bulk metadata editor for Scaleflex DAM — pick fields, apply SET/ADD/DELETE across many assets, preview diffs. Built on @scaleflex/dam-metadata; embeddable without the uploader.
Keywords
Readme
@scaleflex/bulk-edit
The standalone bulk metadata editor for the Scaleflex DAM — pick a field, apply a
SET / ADD / DELETE operation across many assets at once, preview the per-asset diff, and
apply. It ships as a single custom element, <sfx-bulk-metadata-modal>, built on
@scaleflex/dam-metadata and embeddable without the uploader.
The editor is stateless and event-driven: you hand it the assets to edit plus a metadata
schema, and it emits *-save-batch CustomEvents describing the changes. It never calls an API
itself — the host persists the emitted batches (e.g. via @scaleflex/dam-core's services). That
makes it trivial to run fully offline against mock data (see Demo).
Install
npm i @scaleflex/bulk-edit @scaleflex/dam-metadataQuick start
import '@scaleflex/bulk-edit';
// ^ side effect: registers <sfx-bulk-metadata-modal> AND the dam-metadata field components.
import type { BulkEditTarget } from '@scaleflex/bulk-edit';
import type { MetadataSchema } from '@scaleflex/dam-metadata';
// 1. The assets to edit. Read fields: id / name / type / meta / product? / taxonodes?,
// plus previewUrl + size for the per-row thumbnail (see "Preview thumbnails" below).
const files: BulkEditTarget[] = [
{ id: 'a1', name: 'beach.jpg', type: 'image/jpeg', meta: { title: 'Beach', keywords: ['sea'] } },
{ id: 'a2', name: 'forest.jpg', type: 'image/jpeg', meta: {} },
// ...
] as BulkEditTarget[]; // see the BulkEditTarget note below re: the structural superset
// 2. The metadata schema (parsed shape from @scaleflex/dam-metadata).
const schema: MetadataSchema = /* parseMetadataSchema(...) or build by hand */;
// 3. Mount. There is NO `open` flag — the modal is a full-screen overlay that renders
// as soon as it's in the DOM with a valid .schema + .files. Set the props, then append.
const modal = document.createElement('sfx-bulk-metadata-modal');
modal.schema = schema;
modal.files = files;
// 4. Listen for the change batches and persist them yourself.
modal.addEventListener('metadata-save-batch', (e) => persistMeta(e.detail.changes));
modal.addEventListener('product-save-batch', (e) => persistProduct(e.detail.changes));
modal.addEventListener('taxonomy-save-batch', (e) => persistTaxonomy(e.detail.changes));
// 5. Tear down on close (Save / Cancel / Back / ✕ / Escape all emit this).
modal.addEventListener('metadata-close', () => modal.remove());
document.body.appendChild(modal);How it opens / closes
<sfx-bulk-metadata-modal> has no open / visible property. It is a full-screen overlay
that displays whenever it is attached to the DOM and schema.fields is non-empty. To "close" it,
listen for metadata-close and remove the element (or hide it) yourself. There is no internal
visibility state to toggle.
Properties
Set these as properties. The object/service props are attribute: false and have no
attribute at all; the three string enums at the bottom of the table (operationMode,
primaryAction, exitAction) do reflect a lowercased attribute, but setting them as
properties works for all of them and is the only option for the rest:
| Property | Type | Required | Notes |
| ----------------- | ------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------- |
| schema | MetadataSchema | ✅ | Parsed schema: { groups, fields, fieldsByKey, regionalVariantsGroups, language, productsEnabled }. |
| files | BulkEditTarget[] | ✅ | Assets to edit (defaults to []). |
| config | MetadataConfig \| null | — | Drives required-field enforcement + regional filters. |
| dependencies | Dependency[] | — | Pre-upload dependency rules (require / show / hide). Empty disables. |
| defaultLanguage | string | — | Default regional-variant key. |
| initialFieldKey | string \| null | — | Open with this field active instead of the first navigable one. |
| aiFilledFields | AiFilledFields | — | { [fieldKey]: tooltip \| true } — fields an AI post-process fills get a sparkles icon in the sidebar. See below. |
| autocomplete | unknown | — | Autocomplete service, threaded to the field components. |
| taxonomyService | unknown | — | Taxonomy picker service (for taxonomy-node fields). |
| ultratags | unknown | — | Ultratags vocabulary service (for ultratags fields). |
| pickAssets | PickAssetsFn | — | Opens your asset picker for attachments-assets fields. See Attachments below. |
| operationMode | 'full' \| 'set-only' | — | 'set-only' hides the Operation picker and pins Apply to SET. Defaults to 'full'. See below. |
| primaryAction | 'save' \| 'upload' | — | Label of the terminal button once nothing required is missing. Defaults to 'save'. |
| exitAction | 'cancel' \| 'back' | — | Whether leaving without the terminal button discards or keeps the staged changes. Defaults to 'cancel'. See below. |
exitAction — discard on the way out, or hand it back?
'cancel' (default) suits editing assets that already exist: the modal is a transaction over
live data, so the footer offers Cancel and it — along with the top-bar ✕, Escape and an overlay
click — discards everything staged behind a confirmation.
'back' suits a host that is staging assets that don't exist yet (the uploader's pre-upload
flow). There the values are filled field by field and the host keeps them on its own staging files,
so there is no transaction to cancel. The footer offers ← Back to files instead: it emits the
usual *-save-batch events and closes with saved: false, letting the user go add more files or
change the batch and come back to a modal that still has everything they filled. ✕, Escape and an
overlay click do the same, and nothing prompts to discard — no exit can silently drop work.
A live, un-applied op-bar value is still confirmed (offering Apply), since committing would otherwise drop it.
Keep it separate from primaryAction: a host can be mid-pre-upload while the terminal button still
reads "Save", because the user opened the editor voluntarily with nothing required outstanding.
operationMode — when to drop the Operation picker
By default the op-bar offers Set / Add to / Remove from (arrays), Set / Append / Remove (text) or Set / Clear (scalars). That only earns its place when the assets already carry values.
For hosts filling metadata on assets that don't exist yet — the uploader's pre-upload flow —
every current value is empty, so ADD collapses to SET and DELETE is a no-op: three choices,
one outcome. Pass operationMode="set-only" and the picker disappears, the value input takes the
full width, and the button reads "Apply to selected".
Trade-off: a value already staged on individual rows can no longer be bulk-cleared or appended to
in set-only — the per-row editor is the way to change it.
aiFilledFields — mark the fields an AI post-process fills
The Hub marks metadata fields that an AI post-process auto-fills (AI enrichment via the field's
ai_prompt, alt text generation, Describe with AI) with a gradient sparkles icon in Settings,
asset details and the filters. aiFilledFields puts the same mark in the bulk editor's sidebar,
right after the field name, with a tooltip naming the post-process:
modal.aiFilledFields = {
description: 'Filled by AI · Describe with AI post-process',
keywords: 'Filled by AI · AI enrichment post-process',
alt_text: true, // built-in tooltip: "Filled by AI"
};Keys are MetadataField.key. A string value is used as the tooltip, true falls back to the
built-in "Filled by AI", and false / a missing key shows nothing. It is display only — the
field is still edited like any other; flagging it just tells the user that an automation also
writes to it.
The editor cannot derive the flag itself: whether a field is AI-filled depends on project-level settings the host owns (the field's prompt and whether the matching post-process is enabled), so compute the rule host-side and pass the result. The map is a plain property — assign a new object whenever the rule's inputs change (post-processes toggled, language switched) and the sidebar re-renders.
Event contract
All events bubble and are composed: true, so you can also listen on a parent container.
Detail shapes below are read straight from bulk-metadata-modal.ts.
metadata-save-batch
Emitted on Save. Carries only generic metadata fields that changed, per file (unchanged
fields and files are omitted; the meta object is a partial patch, not the full metadata).
e.detail = {
changes: Array<{
fileId: string;
meta: Record<string, unknown>; // only the changed keys, in backend value format
}>;
};product-save-batch
Emitted on Save only when schema.productsEnabled === true and a product field
(ref / position) changed. Clearing a value is represented as undefined ("drop the field"),
not as a sent empty value.
e.detail = {
changes: Array<{
fileId: string;
product: { ref?: string; position?: number }; // Partial<Product>, changed keys only
}>;
};taxonomy-save-batch
Emitted on Save only when a taxonomy-node field's picked entry changed. null means the
entry was cleared.
e.detail = {
changes: Array<{
fileId: string;
taxonodes: Record<string, TaxonodeEntry | null>; // changed field keys only
}>;
};metadata-close
e.detail = { saved: boolean };Emitted when the modal should close; the host removes/hides the element. saved is true
only for the terminal Save — every other exit reports false, so a host can tell "the user
committed and is done" from "the user stepped out".
| Exit | exitAction: 'cancel' (default) | exitAction: 'back' |
| ---------------------------------- | ----------------------------------------- | ----------------------------------------------------------- |
| Footer primary (Save/Upload) | { saved: true }, after the batches | { saved: true }, after the batches |
| Footer secondary | Cancel → discards, { saved: false } | ← Back to files → emits the batches, { saved: false } |
| Top-bar ✕ / Escape / overlay click | discards, { saved: false } | same as Back — emits the batches, { saved: false } |
Under 'cancel' every discarding exit asks for confirmation first; under 'back' nothing is
discarded, so nothing prompts (bar a live, un-applied op-bar value — see exitAction above).
A regional-settings change inside the modal is handled by the nested
<sfx-regional-settings>element and does not surface as a modal-level event.
BulkEditTarget shape
type BulkEditTarget = {
id: string;
name: string;
type: string; // MIME, e.g. 'image/jpeg'
meta: Record<string, unknown>; // current metadata (backend value format)
product?: { ref?: string; position?: number }; // when productsEnabled
taxonodes?: Record<string, TaxonodeEntry | null>;
previewUrl?: string | null; // per-row thumbnail — see "Preview thumbnails"
size?: number; // bytes, for the row's size cell (omit/0 → "—")
};Note (tracked refinement,
plan/06):BulkEditTargetcurrently aliases dam-metadata'sUploadFile— a structural superset that still carries the upload-status field the required-field gate reads. So the uploader'sUploadFilesatisfies it directly, and a standalone consumer constructs an object of the same shape (most extra fields are inert defaults — see the demo'smakeTargetfactory). Narrowing it to the UI-neutral{ id, name, type, meta, product?, taxonodes? }above (by hoisting the status filter out of the engine) is the remaining decoupling step.
Preview thumbnails
Each row shows a thumbnail. The editor renders BulkEditTarget.previewUrl as-is (it's just an
<img src>) and falls back to a file-type icon when previewUrl is null/absent — it never
builds or fetches a preview itself. That keeps the editor stateless and lets it serve two host
scenarios from the same prop:
| Scenario | What previewUrl should be |
| ------------------------------- | ------------------------------------------------------------------------------ |
| New uploads (blob) | a local object URL — URL.createObjectURL(file) (this is the uploader's path) |
| Already-existing DAM assets | a sized, transform-capable CDN thumbnail — built exactly like asset-picker |
The uploader already does the blob case for you: it sets
previewUrlon eachUploadFile(object URL for local files, transformed remote thumbnail for connector/URL imports), and passes those files straight through. No change is needed to bulk-edit, and no new prop — you control the preview entirely by what you put inpreviewUrl.
Existing assets — match asset-picker exactly
To get the same thumbnails asset-picker shows, build the URL with the shared helper
getThumbnailUrl() from @scaleflex/dam-core (it's the asset-picker logic, ported
verbatim: rewrite {token}.filerobot.com → the transform-capable assets.filerobot.com/{token}/
host, size via CDN params, render PDFs' first page, use the video poster, and fall back to ""
for non-thumbnailable types so the icon shows):
import '@scaleflex/bulk-edit';
import type { BulkEditTarget } from '@scaleflex/bulk-edit';
import { getThumbnailUrl } from '@scaleflex/dam-core';
import type { Asset } from '@scaleflex/dam-core';
// `assets` are your already-existing DAM assets (e.g. the asset-picker selection).
const files: BulkEditTarget[] = assets.map(
(a: Asset): BulkEditTarget =>
({
id: a.uuid,
name: a.name,
type: a.fileType ?? '',
meta: currentMetaFor(a.uuid), // the asset's current backend metadata (your source)
size: a.size,
// Same thumbnail asset-picker renders; '' → editor shows the file-type icon.
previewUrl: getThumbnailUrl(a, 96) || null,
// No `status` needed — existing assets are treated as editable by default,
// so the required-field gate applies whenever you pass an enforcing `config`
// (see the note below). `status` is an upload-only field; you can omit it.
}) as BulkEditTarget,
);
const modal = document.createElement('sfx-bulk-metadata-modal');
modal.schema = schema;
modal.files = files;
// …wire the *-save-batch events and persist them (see Quick start).Required-field enforcement &
status.configis the single switch: pass aconfigthat enables required metadata (shouldEnforceRequiredMetadata→ true) and the modal's "Next required" gate is active; don't, and there's no gate. The gate skips files in a non-modifiable uploadstatus(anything other thanidle/queued/rejected) — that's how the uploader excludes files already mid-upload. Existing assets carry nostatus, and the modal treats a missingstatusas modifiable, so the gate applies to them automatically — you do not need to setstatus: 'idle'(it's an upload-only field; omit it).
- Want the required-field gate on existing assets? Just pass an enforcing
config. ✅ (default)- Want pure free-form bulk editing (no gating)? Don't pass an enforcing
config.Saving itself is never status-gated —
*-save-batchis emitted for every edited file regardless of status. Only the required-field block depends on the gate.
getThumbnailUrlexpects a normalizedAsset, not a raw Filerobot API object. This is the #1 cause of "no previews": it readsasset.thumbnailUrl,asset.cdnUrl, and a classifiedasset.type('image'/'video'/'document'/ …) — not the rawinfo.image_thumbnail/url.cdn/ MIME-string fields. Fed a raw object, every field it reads isundefinedand it returns''→ the editor shows the file-type icon for everything. Get normalizedAssets from dam-core'sgetFiles(client, …)/getFilesByUuids(client, uuids)(they map the raw fields with the same source priority js-admin uses), or map the raw fields yourself first:previewUrl: getThumbnailUrl( { uuid: raw.uuid, name: raw.name, folder: raw.folder ?? '/', type: classify(raw), // 'image' | 'video' | 'document' | … — NOT the MIME string fileType: raw.file_type ?? raw.type, cdnUrl: raw.cdn_permalink ?? raw.cdn_url, thumbnailUrl: raw.info?.video_thumbnail ?? raw.info?.image_thumbnail ?? raw.info?.thumbnail ?? raw.info?.preview, size: typeof raw.size === 'object' ? raw.size?.bytes : raw.size, }, 100, ) || null;
bulk-edit itself does not depend on @scaleflex/dam-core (it stays offline/stateless) — the
getThumbnailUrl call lives in your host code, alongside wherever you already fetch the assets. If
you don't want the dam-core dependency, set previewUrl to any image URL you can build yourself; a
plain CDN URL works, it just won't be auto-sized. A live, runnable version of this is the
Existing assets example in the demo site.
Already have a thumbnail builder? Reuse it (don't pull in dam-core)
previewUrl accepts any image URL — getThumbnailUrl is a convenience, not a requirement. If
your host app already builds asset thumbnails, reuse that function and skip the dam-core route
entirely. For example, js-admin-react-filerobot-v5 has getItemIcon(), which the app's own
list rows already use; an adapter that maps the host's file shape (HubFile) to BulkEditTarget[]
should just call it:
import { getItemIcon } from '@/utils/get-item-icon';
import { LAYOUTS_IDS } from '@/.../layouts.constants';
export function adaptHubFilesToBulkEditTargets(
hubFiles: HubFile[],
opts: { container?: string | null; isDevEnv?: boolean } = {},
): BulkEditTarget[] {
return hubFiles.map(
(f) =>
({
id: f.uuid || f.id,
name: f.name ?? '',
type: f.type ?? '',
meta: f.meta ?? {},
size: f.size?.bytes ?? 0,
// ⛔ The #1 "no previews" bug is hardcoding `previewUrl: null` here.
// ✅ Reuse the host's own thumbnail builder instead:
previewUrl: getItemIcon({
fileOrFolder: f,
viewLayout: LAYOUTS_IDS.LIST, // small row-sized thumbnail
container: opts.container, // REQUIRED for the assets.filerobot.com host rewrite
isDevEnv: opts.isDevEnv,
}),
// No `status` — existing assets default to "editable", so the required-field gate
// applies when you pass an enforcing `config`. (Setting a non-modifiable status like
// `'complete'` would disable that gate — only do so for deliberately free-form editing.)
}) as BulkEditTarget,
);
}Two things to know with this path:
- Pass the container/token (
containerabove). Without it the host rewrite toassets.filerobot.comdoesn't fire and private-asset thumbnails won't load with transform params. getItemIconnever returnsnull— for audio / 3D / generic types it returns a file-type-icon URL, so the editor renders that icon directly as the row thumbnail (instead of via its own fallback). Same result visually; just don't expectnull.
Headless bulk-operations API
The package also exports the framework-agnostic operation logic from
bulk-operations — the same SET/ADD/DELETE semantics the modal uses,
usable without the UI:
| Export | Purpose |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| getAvailableOperations(type) | The operations a field type exposes. Arrays → Set / Add to / Remove from; text → Set / Append / Remove; scalars → Set / Clear; unsupported types → []. |
| applyBulkOperation(op, current, value, type) | Core merge: SET overwrites; ADD merges+dedups arrays / concatenates text; DELETE removes array items, substring-removes text, or clears scalars. |
| computeBulkResult(field, currentBackend, frontendValue, op, language?) | Full per-file result: frontend→backend conversion + regional-variant unwrap/rewrap, then applyBulkOperation. |
| clampBackendValueToAllowed(field, value, allowed, language?, prevValue?) | Restrict a select-one / multi-select result to a file's allow_values set. Returns CLAMP_DROP to skip the file. |
| isValueRequiredForPreview(op, type) | Whether a non-empty value is needed (scalar DELETE/"Clear" needs none). |
| ARRAY_TYPES, TEXT_TYPES, CLAMP_DROP | Field-type sets + the skip sentinel. |
| types BulkOperation, BulkOperationDef, PendingOp | 'SET' \| 'ADD' \| 'DELETE' and friends. |
import { getAvailableOperations, applyBulkOperation } from '@scaleflex/bulk-edit';
getAvailableOperations('tags'); // [{ key:'SET',… }, { key:'ADD',… }, { key:'DELETE',… }]
applyBulkOperation('ADD', ['a', 'b'], ['b', 'c'], 'tags'); // → ['a', 'b', 'c'] (dedup)
applyBulkOperation('SET', 'old', 'new', 'text'); // → 'new'Demo
A runnable, fully offline demo site (mock schema + mock assets, no credentials) lives in
demo/ — a multi-page docs + examples app (topbar, sidebar nav, live launchers, code
blocks), mirroring the asset-picker / uploader demos:
pnpm --filter @scaleflex/bulk-edit dev # serve the docs + examples site
pnpm --filter @scaleflex/bulk-edit build:demo # static build → demo-dist/It documents the props/events/types, walks through the SET/ADD/DELETE operations, and lets you
launch the editor over mock data and pretty-prints every emitted batch so you can see the exact
result. A minimal single-page playground also lives in dev/ for quick manual testing.
Standalone / CDN
For a one-<script>-tag embed, build the self-contained IIFE bundle (lit +
@scaleflex/dam-metadata inlined):
pnpm --filter @scaleflex/bulk-edit build:cdn # → dist-cdn/bulk-edit.min.jsThen load it directly — see examples/standalone.html for a complete,
self-contained page that registers <sfx-bulk-metadata-modal> and runs the same mock-data flow:
<script src="https://your-cdn/bulk-edit.min.js"></script>
<script type="module">
const modal = document.createElement('sfx-bulk-metadata-modal');
modal.schema = schema;
modal.files = files;
modal.addEventListener('metadata-save-batch', (e) => persist(e.detail.changes));
document.body.appendChild(modal);
</script>Attachments (attachments-assets)
attachments-assets fields (a list of up to 5 references to other assets) can be bulk edited. The only
operation is Overwrite: it removes every existing attachment from the selected assets before adding
the chosen ones, so the Operation picker is shown muted. Apply asks for confirmation first.
The editor never opens an asset picker itself — the host does, through the pickAssets property:
modal.pickAssets = async ({ max, excludeIds, field }) => {
// Open your picker (e.g. <sfx-asset-picker>) and resolve with the chosen assets, or null on cancel.
const assets = await openPicker({
multiSelect: true,
maxSelections: max, // how many more attachments fit
maxSelectionsBehavior: 'lock', // mute the rest once the cap is hit
disabledAssetIds: excludeIds, // the edited assets and their existing attachments
hideUpload: true, // only existing assets can be attached
});
return assets?.map((a) => ({ uuid: a.uuid, name: a.name, thumbnailUrl: a.url?.cdn }));
};- Without
pickAssetsthe field is treated as unsupported (the lock notice, no editor) — attachments point at existing assets, so hosts that edit assets before they exist (the uploader's pre-upload flow) get no editor. - Values read from
file.meta[fieldKey]may be uuid strings or objects with auuid(and ideallyname/thumbnailUrlfor the chips); the savedmetadata-save-batchchanges[].meta[fieldKey]is always an array of uuids. - The per-asset preview shows what Apply will do: new attachments in green, removed ones in red.
