@scaleflex/bulk-edit
@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 / Close / 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 (not attributes — they're all attribute: false):
| 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. |
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). |
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
Emitted with no detail when the modal should close — fired on Save (after the batches), Cancel, Back, the top-bar ✕, and Escape. The host removes/hides the element.
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>