Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/admin-dam-video-block-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@dextinity/cms-admin": minor
---

Add `name` option to `createDamVideoBlock`

A `DamVideoBlock` created with a custom `supports` needs a name of its own, because `DamVideo` is taken by the exported `DamVideoBlock`. The name must match the name of the block created with `createDamVideoBlock` in the API.

**Example**

```tsx
import { createDamVideoBlock } from "@dextinity/cms-admin";

export const TeaserVideoBlock = createDamVideoBlock({ name: "TeaserVideo", supports: [] });
```
33 changes: 33 additions & 0 deletions .changeset/api-dam-video-block-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@dextinity/cms-api": minor
---

Add `createDamVideoBlock` factory

The `DamVideoBlock` always stores everything it has (autoplay, loop, show controls, preview image), even for sites that don't use any of it. `createDamVideoBlock` is the API counterpart of the Admin factory of the same name: pass what the site supports via `supports`, anything left out is part of neither the block's data nor its input, so it doesn't show up in `blocks.generated.ts` and isn't stored for new content.
Values that were stored before an option was left out are kept and saved again, so narrowing `supports` doesn't remove them from existing content.

`supports` takes:

- `"controls"` — autoplay, loop and show controls, offered together
- `"previewImage"` — the poster image

`DamVideoBlock` is now created from the factory with both supported and still exported next to it, so this is non-breaking. Since it occupies the block name `DamVideo`, a block created with the factory needs a name of its own.
The name is passed as the second parameter, the same `nameOrOptions` the other block factories take, so it can carry a `migrate` option as well.

**Example**

```ts
import { createDamVideoBlock } from "@dextinity/cms-api";

// For a site that only reads the video's URL
export const TeaserVideoBlock = createDamVideoBlock({ supports: [] }, "TeaserVideo");
```

Use the same `supports` and the same name for the Admin block.

**Preview image of existing content**

A block created by the factory defaults a missing preview image to an empty one when it supports one. This matters when the factory replaces a block a project already has: content stored before the block had a preview image loads as a child block instead of leaving the field undefined, which the block's own meta declares as always present.

The default applies on read, so it also reaches content whose version is already the block's latest — for instance content stored while `supports` left the preview image out, before it was widened to include it.
82 changes: 71 additions & 11 deletions docs/docs/2-core-concepts/2-blocks/4-factories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,30 +227,90 @@ export const FullWidthImageBlock = createCompositeBlock({
});
```

## DamVideoBlock (Admin only)
## DamVideoBlock

The DamVideoBlock factory creates a block for a video from the DAM.
Use `supports` to restrict what editors can set besides the video file itself:
Use `supports` to restrict what the block offers besides the video file itself:

- `"controls"`: The playback options autoplay, loop and show controls.
- `"previewImage"`: The poster image shown before playback.

Both are supported by default.
`@dextinity/cms-admin` exports a ready-made `DamVideoBlock` created with those defaults, use the `createDamVideoBlock` factory to change them:
`@dextinity/cms-api` and `@dextinity/cms-admin` export a ready-made `DamVideoBlock` created with those defaults.
Use the `createDamVideoBlock` factory when a site doesn't need all of them, for instance an app integration that only reads the video's URL.

```tsx title="DamVideoBlock.tsx"
Give the block a name of its own — `DamVideo` is taken by the exported `DamVideoBlock` — and use that same name and the same `supports` in both the API and the Admin.

### API

Use the `createDamVideoBlock` factory:

```ts title="teaser-video.block.ts"
import { createDamVideoBlock } from "@dextinity/cms-api";

// For a site that only reads the video's URL
export const TeaserVideoBlock = createDamVideoBlock({ supports: [] }, "TeaserVideo");
```

Unsupported options are part of neither the block's data nor its input, so they don't show up in `blocks.generated.ts` and aren't stored for new content.
Values that were stored before an option was left out are kept: the block loads them and saves them again, so narrowing `supports` doesn't remove them from existing content.
Note that such a value is no longer treated as a child block, so a preview image kept this way isn't part of the block index either.

#### Preview image of existing content

A block that supports the preview image defaults a missing one to an empty preview image when it loads content.
So replacing a block a project already has works without a migration: content stored before the preview image existed loads as a child block instead of leaving the field undefined.

The default applies on read, which also covers content that no migration reaches — for instance content stored while `supports` left the preview image out, before it was widened to include it.

Run `migrateBlocks` to write the defaulted data back:

```bash
pnpm console migrateBlocks
```

Without it the default still applies whenever a block is loaded, it just isn't persisted until the content is saved.

Own migrations are passed via `migrate` and start with version 1, like for any other block:

```ts title="teaser-video.block.ts"
import { createDamVideoBlock, typeSafeBlockMigrationPipe } from "@dextinity/cms-api";

export const TeaserVideoBlock = createDamVideoBlock(
{ supports: ["controls"] },
{ name: "TeaserVideo", migrate: { version: 1, migrations: typeSafeBlockMigrationPipe([AddSomethingMigration]) } },
);
```

### Admin

Use the `createDamVideoBlock` factory:

```tsx title="TeaserVideoBlock.tsx"
import { createDamVideoBlock } from "@dextinity/cms-admin";

// For a site that renders no poster image
export const DamVideoBlock = createDamVideoBlock({ supports: ["controls"] });
export const TeaserVideoBlock = createDamVideoBlock({ name: "TeaserVideo", supports: [] });
```

:::note
Unsupported options are hidden from the editor.
Their stored values are kept as long as the API block supports them, so widening `supports` later brings them back.

Leaving out an option only hides it from the editor.
Values that are already stored are kept as they are.
The preview image in particular stays part of the block's data either way, since the API's child block is non-nullable.
:::
### Site

Render the video with the fields the block supports:

```tsx title="TeaserVideoBlock.tsx"
import { PropsWithData } from "@dextinity/site-nextjs";
import { TeaserVideoBlockData } from "@src/blocks.generated";

export function TeaserVideoBlock({ data: { damFile } }: PropsWithData<TeaserVideoBlockData>) {
if (!damFile) {
return null;
}

return <video src={damFile.fileUrl} />;
}
```

## FinalFormBlock (Admin only)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ describe("createDamVideoBlock", () => {
expect(createDamVideoBlock().name).toBe("DamVideo");
});

it("should allow setting the name", () => {
expect(createDamVideoBlock({ name: "TeaserVideo" }).name).toBe("TeaserVideo");
});

it("should allow overriding the tags", () => {
expect(createDamVideoBlock({ tags: ["Movie"] }).tags).toEqual(["Movie"]);
});
Expand Down
11 changes: 9 additions & 2 deletions packages/admin/cms-admin/src/blocks/createDamVideoBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@ type DamVideoBlockSupports = "controls" | "previewImage";
const defaultSupports: DamVideoBlockSupports[] = ["controls", "previewImage"];

interface DamVideoBlockFactoryOptions {
/**
* The block's name. Must match the name of the block created with `createDamVideoBlock` in the API.
* @default "DamVideo"
*/
name?: string;
/**
* What the editor can set besides the video file itself. Leave out anything the site implementation
* doesn't use, for instance `["controls"]` for a site that renders no poster image, or `[]` for a site
* that only reads the file's URL.
*
* Values that are already stored are kept as they are, the editor just can't change them anymore.
* The preview image is always part of the block's data, leaving it out only hides it from the editor.
* Whether an option is part of the block's data at all is decided by the API block, so use the same
* `supports` there.
* @default ["controls", "previewImage"]
*/
supports?: DamVideoBlockSupports[];
Expand All @@ -47,6 +53,7 @@ interface DamVideoBlockFactoryOptions {

export const createDamVideoBlock = (
{
name = "DamVideo",
supports = defaultSupports,
tags = [defineMessage({ id: "dextinity.damVideoBlock.tag.video", defaultMessage: "Video" })],
}: DamVideoBlockFactoryOptions = {},
Expand All @@ -57,7 +64,7 @@ export const createDamVideoBlock = (
const DamVideoBlock: BlockInterface<DamVideoBlockData, DamVideoBlockState, DamVideoBlockInput> = {
...createBlockSkeleton(),

name: "DamVideo",
name,

displayName: <FormattedMessage id="dextinity.blocks.damVideo" defaultMessage="Video (CMS Asset)" />,

Expand Down
158 changes: 158 additions & 0 deletions packages/api/cms-api/src/dam/blocks/video/createDamVideoBlock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";

import { transformToBlockSave } from "../../../blocks/block";
import { BlockMigration } from "../../../blocks/migrations/BlockMigration";
import type { BlockMigrationInterface } from "../../../blocks/migrations/types";
import { typeSafeBlockMigrationPipe } from "../../../blocks/migrations/typeSafeBlockMigrationPipe";
import { createDamVideoBlock, DamVideoBlock } from "./createDamVideoBlock";

class AddLoopMigration
extends BlockMigration<(from: { damFileId?: string }) => { damFileId?: string; loop: boolean }>
implements BlockMigrationInterface
{
public readonly toVersion = 1;

protected migrate(props: { damFileId?: string }) {
return { ...props, loop: true };
}
}

const damFileId = "0a3a4f9c-1b19-4f7e-bd0a-8e0b6b1a2c3d";

describe("createDamVideoBlock", () => {
it("should support controls and preview image by default", () => {
expect(DamVideoBlock.name).toBe("DamVideo");
expect(DamVideoBlock.blockMeta.fields.map((field) => field.name)).toEqual(["autoplay", "showControls", "loop", "previewImage", "damFile"]);
expect(DamVideoBlock.blockInputMeta.fields.map((field) => field.name)).toEqual([
"autoplay",
"showControls",
"loop",
"previewImage",
"damFileId",
]);
});

it("should leave out the preview image when it isn't supported", () => {
const block = createDamVideoBlock({ supports: ["controls"] }, "VideoWithoutPreviewImage");

expect(block.blockMeta.fields.map((field) => field.name)).toEqual(["autoplay", "showControls", "loop", "damFile"]);
expect(block.blockInputMeta.fields.map((field) => field.name)).toEqual(["autoplay", "showControls", "loop", "damFileId"]);
});

it("should leave out everything but the file when nothing is supported", () => {
const block = createDamVideoBlock({ supports: [] }, "FileOnlyVideo");

expect(block.blockMeta.fields.map((field) => field.name)).toEqual(["damFile"]);
expect(block.blockInputMeta.fields.map((field) => field.name)).toEqual(["damFileId"]);
});

it("should store only the supported options", () => {
const block = createDamVideoBlock({ supports: [] }, "StoringFileOnlyVideo");
const input = block.blockInputFactory({ damFileId, autoplay: true, showControls: true, loop: true, previewImage: {} });

expect(transformToBlockSave(input.transformToBlockData())).toEqual({ damFileId });
});

it("should create input for a block without a preview image", () => {
const block = createDamVideoBlock({ supports: ["controls"] }, "InputWithoutPreviewImage");
const input = block.blockInputFactory({ damFileId, autoplay: true });

expect(transformToBlockSave(input.transformToBlockData())).toEqual({ damFileId, autoplay: true });
});

it("should create input for a block that supports nothing but the file", () => {
const block = createDamVideoBlock({ supports: [] }, "InputWithFileOnly");
const input = block.blockInputFactory({ damFileId });

expect(transformToBlockSave(input.transformToBlockData())).toEqual({ damFileId });
});

it("should store the supported options", () => {
const block = createDamVideoBlock({}, "StoringFullVideo");
const input = block.blockInputFactory({ damFileId, autoplay: true, previewImage: {} });

expect(transformToBlockSave(input.transformToBlockData())).toEqual({ damFileId, autoplay: true, previewImage: {} });
});

it("should require a preview image in the input when it is supported", () => {
const block = createDamVideoBlock({}, "InputRequiringPreviewImage");

expect(() => block.blockInputFactory({ damFileId })).toThrow(/Missing child block input for 'previewImage'/);
});

it("should reject a name that is already registered", () => {
expect(() => createDamVideoBlock({ supports: [] })).toThrow(/already registered/);
});
});

describe("createDamVideoBlock preview image default", () => {
it("should default a missing preview image to an empty one", () => {
const block = createDamVideoBlock({}, "DefaultingPreviewImage");
const data = block.blockDataFactory({ damFileId, autoplay: true });

expect(data.previewImage?.constructor.name).toBe("PixelImageBlockData");
expect(data.childBlocksInfo().map((child) => child.name)).toEqual(["PixelImage"]);
expect(transformToBlockSave(data)).toEqual({ damFileId, autoplay: true, previewImage: {} });
});

it("should default a preview image of content that is past the block's migrations", () => {
// Content stored while the preview image wasn't supported: no migration reaches it, because its
// version is already the block's latest.
const block = createDamVideoBlock({}, { name: "DefaultingPastMigrations", migrate: { version: 1, migrations: [AddLoopMigration] } });
const data = block.blockDataFactory({ damFileId, autoplay: true, $$version: 1 });

expect(data.previewImage?.constructor.name).toBe("PixelImageBlockData");
expect(transformToBlockSave(data)).toEqual({ damFileId, autoplay: true, previewImage: {}, $$version: 1 });
});

it("should keep a stored preview image", () => {
const block = createDamVideoBlock({}, "KeepingPreviewImage");

expect(transformToBlockSave(block.blockDataFactory({ damFileId, previewImage: { damFileId } }))).toEqual({
damFileId,
previewImage: { damFileId },
});
});

it("should not add a preview image to a block that doesn't support one", () => {
const block = createDamVideoBlock({ supports: ["controls"] }, "NoDefaultWithoutSupport");

expect(transformToBlockSave(block.blockDataFactory({ damFileId, autoplay: true }))).toEqual({ damFileId, autoplay: true });
});

it("should default the preview image only once", () => {
const block = createDamVideoBlock({}, "DefaultingOnlyOnce");
const once = transformToBlockSave(block.blockDataFactory({ damFileId }));

expect(transformToBlockSave(block.blockDataFactory(once))).toEqual(once);
});
});

describe("createDamVideoBlock migrations", () => {
it("should add a preview image to data from before the exported block had one", () => {
expect(transformToBlockSave(DamVideoBlock.blockDataFactory({ damFileId }))).toEqual({ damFileId, previewImage: {}, $$version: 1 });
});

it("should keep a preview image that the exported block stored without a version", () => {
expect(transformToBlockSave(DamVideoBlock.blockDataFactory({ damFileId, previewImage: { damFileId } }))).toEqual({
damFileId,
previewImage: { damFileId },
$$version: 1,
});
});

it("should neither migrate nor version data of a block created by the factory", () => {
const block = createDamVideoBlock({ supports: [] }, "UnmigratedVideo");

expect(transformToBlockSave(block.blockDataFactory({ damFileId }))).toEqual({ damFileId });
});

it("should apply migrations passed to the factory", () => {
const block = createDamVideoBlock(
{ supports: [] },
{ name: "MigratedVideo", migrate: { version: 1, migrations: typeSafeBlockMigrationPipe([AddLoopMigration]) } },
);

expect(transformToBlockSave(block.blockDataFactory({ damFileId }))).toEqual({ damFileId, loop: true, $$version: 1 });
});
});
Loading
Loading