npm.io
0.3.3 • Published 7h ago

@scaleflex/bulk-edit

Licence
SEE LICENSE IN LICENSE
Version
0.3.3
Deps
4
Size
235 kB
Vulns
0
Weekly
0

@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-metadata

Quick 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 thumbnailsee "Preview thumbnails"
  size?: number; // bytes, for the row's size cell (omit/0 → "—")
};

Note (tracked refinement, plan/06): BulkEditTarget currently aliases dam-metadata's UploadFile — a structural superset that still carries the upload-status field the required-field gate reads. So the uploader's UploadFile satisfies it directly, and a standalone consumer constructs an object of the same shape (most extra fields are inert defaults — see the demo's makeTarget factory). 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 previewUrl on each UploadFile (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 in previewUrl.

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. config is the single switch: pass a config that 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 upload status (anything other than idle / queued / rejected) — that's how the uploader excludes files already mid-upload. Existing assets carry no status, and the modal treats a missing status as modifiable, so the gate applies to them automatically — you do not need to set status: '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-batch is emitted for every edited file regardless of status. Only the required-field block depends on the gate.

getThumbnailUrl expects a normalized Asset, not a raw Filerobot API object. This is the #1 cause of "no previews": it reads asset.thumbnailUrl, asset.cdnUrl, and a classified asset.type ('image' / 'video' / 'document' / …) — not the raw info.image_thumbnail / url.cdn / MIME-string fields. Fed a raw object, every field it reads is undefined and it returns '' → the editor shows the file-type icon for everything. Get normalized Assets from dam-core's getFiles(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 (container above). Without it the host rewrite to assets.filerobot.com doesn't fire and private-asset thumbnails won't load with transform params.
  • getItemIcon never returns null — 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 expect null.

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

Then 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>