Skip to content

Commit

Permalink
Add support for literal arrays to block meta (#2622)
Browse files Browse the repository at this point in the history
Block meta was missing support for literal arrays, e.g., `string[]`. As a workaround devs would use `string` and join the array elements with a separator (e.g., `,`). This was not ideal. We therefore add support for string, number, boolean, and JSON arrays. Arrays can be defined by setting `array: true` (inspired by MikroORM). An explicit type annotation (e.g., `type: "string"`) is also necessary since the design type only states that it's an array, but no which type (Nest requires this as well).
  • Loading branch information
johnnyomair authored Oct 21, 2024
1 parent e2eb826 commit 9e2b0fa
Show file tree
Hide file tree
Showing 17 changed files with 261 additions and 7 deletions.
17 changes: 17 additions & 0 deletions .changeset/spicy-panthers-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@comet/blocks-api": minor
"@comet/cli": minor
---

Add support for literal arrays to block meta

String, number, boolean, and JSON arrays can be defined by setting `array: true`.

**Example**

```ts
class NewsListBlockData {
@BlockField({ type: "string", array: true })
newsIds: string[];
}
```
74 changes: 74 additions & 0 deletions demo/admin/src/news/blocks/NewsListBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { gql, useQuery } from "@apollo/client";
import { GridColDef, useBufferedRowCount, useDataGridRemote, usePersistentColumnState } from "@comet/admin";
import { BlockInterface, createBlockSkeleton } from "@comet/blocks-admin";
import { Box } from "@mui/material";
import { DataGridPro } from "@mui/x-data-grid-pro";
import { NewsListBlockData, NewsListBlockInput } from "@src/blocks.generated";
import { useContentScope } from "@src/common/ContentScopeProvider";
import { FormattedMessage, useIntl } from "react-intl";

import { GQLNewsListBlockNewsFragment, GQLNewsListBlockQuery, GQLNewsListBlockQueryVariables } from "./NewsListBlock.generated";

type State = {
ids: string[];
};

export const NewsListBlock: BlockInterface<NewsListBlockData, State, NewsListBlockInput> = {
...createBlockSkeleton(),
name: "NewsList",
displayName: <FormattedMessage id="blocks.newsList.name" defaultMessage="News List" />,
defaultValues: () => ({ ids: [] }),
AdminComponent: ({ state, updateState }) => {
const { scope } = useContentScope();
const dataGridProps = { ...useDataGridRemote(), ...usePersistentColumnState("NewsListBlock") };
const intl = useIntl();

const columns: GridColDef<GQLNewsListBlockNewsFragment>[] = [
{ field: "title", headerName: intl.formatMessage({ id: "news.title", defaultMessage: "Title" }), width: 150 },
];

const { data, loading, error } = useQuery<GQLNewsListBlockQuery, GQLNewsListBlockQueryVariables>(
gql`
query NewsListBlock($scope: NewsContentScopeInput!) {
newsList(scope: $scope) {
nodes {
id
...NewsListBlockNews
}
totalCount
}
}
fragment NewsListBlockNews on News {
title
}
`,
{ variables: { scope } },
);
const rowCount = useBufferedRowCount(data?.newsList.totalCount);

if (error) {
throw error;
}

const rows = data?.newsList.nodes ?? [];

return (
<Box sx={{ height: 500 }}>
<DataGridPro
{...dataGridProps}
rows={rows}
rowCount={rowCount}
columns={columns}
loading={loading}
checkboxSelection
disableSelectionOnClick
keepNonExistentRowsSelected
selectionModel={state.ids}
onSelectionModelChange={(newSelection) => {
updateState({ ids: newSelection as string[] });
}}
/>
</Box>
);
},
};
2 changes: 2 additions & 0 deletions demo/admin/src/pages/PageContentBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RichTextBlock } from "@src/common/blocks/RichTextBlock";
import { SpaceBlock } from "@src/common/blocks/SpaceBlock";
import { TextImageBlock } from "@src/common/blocks/TextImageBlock";
import { NewsDetailBlock } from "@src/news/blocks/NewsDetailBlock";
import { NewsListBlock } from "@src/news/blocks/NewsListBlock";
import { userGroupAdditionalItemFields } from "@src/userGroups/userGroupAdditionalItemFields";
import { UserGroupChip } from "@src/userGroups/UserGroupChip";
import { UserGroupContextMenuItem } from "@src/userGroups/UserGroupContextMenuItem";
Expand All @@ -32,6 +33,7 @@ export const PageContentBlock = createBlocksBlock({
media: MediaBlock,
teaser: TeaserBlock,
newsDetail: NewsDetailBlock,
newsList: NewsListBlock,
},
additionalItemFields: {
...userGroupAdditionalItemFields,
Expand Down
25 changes: 23 additions & 2 deletions demo/api/block-meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,25 @@
}
]
},
{
"name": "NewsList",
"fields": [
{
"name": "ids",
"kind": "String",
"nullable": false,
"array": true
}
],
"inputFields": [
{
"name": "ids",
"kind": "String",
"nullable": false,
"array": true
}
]
},
{
"name": "OptionalPixelImage",
"fields": [
Expand Down Expand Up @@ -1286,7 +1305,8 @@
"twoLists": "TwoLists",
"media": "Media",
"teaser": "Teaser",
"newsDetail": "NewsDetail"
"newsDetail": "NewsDetail",
"newsList": "NewsList"
},
"nullable": false
},
Expand Down Expand Up @@ -1342,7 +1362,8 @@
"twoLists": "TwoLists",
"media": "Media",
"teaser": "Teaser",
"newsDetail": "NewsDetail"
"newsDetail": "NewsDetail",
"newsList": "NewsList"
},
"nullable": false
},
Expand Down
1 change: 1 addition & 0 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@ type Query {
news(id: ID!): News!
newsBySlug(slug: String!, scope: NewsContentScopeInput!): News
newsList(offset: Int! = 0, limit: Int! = 25, scope: NewsContentScopeInput!, status: [NewsStatus!]! = [Active], search: String, filter: NewsFilter, sort: [NewsSort!]): PaginatedNews!
newsListByIds(ids: [ID!]!): [News!]!
mainMenu(scope: PageTreeNodeScopeInput!): MainMenu!
topMenu(scope: PageTreeNodeScopeInput!): [PageTreeNode!]!
mainMenuItem(pageTreeNodeId: ID!): MainMenuItem!
Expand Down
19 changes: 19 additions & 0 deletions demo/api/src/news/blocks/news-list.block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { BlockData, BlockField, BlockInput, createBlock, inputToData } from "@comet/blocks-api";
import { IsUUID } from "class-validator";

export class NewsListBlockData extends BlockData {
@BlockField({ type: "string", array: true })
ids: string[];
}

class NewsListBlockInput extends BlockInput {
@BlockField({ type: "string", array: true })
@IsUUID(undefined, { each: true })
ids: string[];

transformToBlockData(): NewsListBlockData {
return inputToData(NewsListBlockData, this);
}
}

export const NewsListBlock = createBlock(NewsListBlockData, NewsListBlockInput, "NewsList");
24 changes: 24 additions & 0 deletions demo/api/src/news/extended-news.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AffectedEntity, RequiredPermission } from "@comet/cms-api";
import { InjectRepository } from "@mikro-orm/nestjs";
import { EntityRepository } from "@mikro-orm/postgresql";
import { Args, ID, Query, Resolver } from "@nestjs/graphql";

import { News } from "./entities/news.entity";

@Resolver(() => News)
@RequiredPermission("news")
export class ExtendedNewsResolver {
constructor(@InjectRepository(News) private readonly repository: EntityRepository<News>) {}

@Query(() => [News])
@AffectedEntity(News, { idArg: "ids" })
async newsListByIds(@Args("ids", { type: () => [ID] }) ids: string[]): Promise<News[]> {
const newsList = await this.repository.find({ id: { $in: ids } });

if (newsList.length !== ids.length) {
throw new Error("Failed to load all requested news");
}

return newsList.sort((newsA, newsB) => ids.indexOf(newsA.id) - ids.indexOf(newsB.id));
}
}
2 changes: 2 additions & 0 deletions demo/api/src/news/news.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { News, NewsContentScope } from "@src/news/entities/news.entity";

import { NewsLinkBlockTransformerService } from "./blocks/news-link-block-transformer.service";
import { NewsComment } from "./entities/news-comment.entity";
import { ExtendedNewsResolver } from "./extended-news.resolver";
import { NewsResolver } from "./generated/news.resolver";
import { NewsCommentResolver } from "./news-comment.resolver";
import { NewsFieldResolver } from "./news-field.resolver";
Expand All @@ -18,6 +19,7 @@ import { NewsFieldResolver } from "./news-field.resolver";
DependenciesResolverFactory.create(News),
DependentsResolverFactory.create(News),
NewsLinkBlockTransformerService,
ExtendedNewsResolver,
],
exports: [],
})
Expand Down
2 changes: 2 additions & 0 deletions demo/api/src/pages/blocks/page-content.block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LinkListBlock } from "@src/common/blocks/link-list.block";
import { RichTextBlock } from "@src/common/blocks/rich-text.block";
import { SpaceBlock } from "@src/common/blocks/space.block";
import { NewsDetailBlock } from "@src/news/blocks/news-detail.block";
import { NewsListBlock } from "@src/news/blocks/news-list.block";
import { UserGroup } from "@src/user-groups/user-group";
import { IsEnum } from "class-validator";

Expand All @@ -29,6 +30,7 @@ const supportedBlocks = {
media: MediaBlock,
teaser: TeaserBlock,
newsDetail: NewsDetailBlock,
newsList: NewsListBlock,
};

class BlocksBlockItemData extends BaseBlocksBlockItemData(supportedBlocks) {
Expand Down
2 changes: 2 additions & 0 deletions demo/site/src/blocks/PageContentBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PageContentBlockData } from "@src/blocks.generated";
import { CookieSafeYouTubeVideoBlock } from "@src/blocks/CookieSafeYouTubeVideoBlock";
import { TeaserBlock } from "@src/documents/pages/blocks/TeaserBlock";
import { NewsDetailBlock } from "@src/news/blocks/NewsDetailBlock";
import { NewsListBlock } from "@src/news/blocks/NewsListBlock";

import { AnchorBlock } from "./AnchorBlock";
import { ColumnsBlock } from "./ColumnsBlock";
Expand Down Expand Up @@ -33,6 +34,7 @@ const supportedBlocks: SupportedBlocks = {
twoLists: (props) => <TwoListsBlock data={props} />,
teaser: (props) => <TeaserBlock data={props} />,
newsDetail: (props) => <NewsDetailBlock data={props} />,
newsList: (props) => <NewsListBlock data={props} />,
};

export const PageContentBlock = ({ data }: PropsWithData<PageContentBlockData>) => {
Expand Down
35 changes: 35 additions & 0 deletions demo/site/src/news/blocks/NewsListBlock.loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { BlockLoader, gql } from "@comet/cms-site";
import { NewsListBlockData } from "@src/blocks.generated";

import { GQLNewsListBlockNewsFragment, GQLNewsListBlockQuery, GQLNewsListBlockQueryVariables } from "./NewsListBlock.loader.generated";

export type LoadedData = GQLNewsListBlockNewsFragment[];

export const loader: BlockLoader<NewsListBlockData> = async ({ blockData, graphQLFetch }): Promise<LoadedData> => {
if (blockData.ids.length === 0) {
return [];
}

const data = await graphQLFetch<GQLNewsListBlockQuery, GQLNewsListBlockQueryVariables>(
gql`
query NewsListBlock($ids: [ID!]!) {
newsListByIds(ids: $ids) {
...NewsListBlockNews
}
}
fragment NewsListBlockNews on News {
id
title
slug
scope {
domain
language
}
}
`,
{ ids: blockData.ids },
);

return data.newsListByIds;
};
24 changes: 24 additions & 0 deletions demo/site/src/news/blocks/NewsListBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { PropsWithData, withPreview } from "@comet/cms-site";
import { NewsListBlockData } from "@src/blocks.generated";
import Link from "next/link";

import { LoadedData } from "./NewsListBlock.loader";

export const NewsListBlock = withPreview(
({ data: { loaded: newsList } }: PropsWithData<NewsListBlockData & { loaded: LoadedData }>) => {
if (newsList.length === 0) {
return null;
}

return (
<ol>
{newsList.map((news) => (
<li key={news.id}>
<Link href={`/${news.scope.language}/news/${news.slug}`}>{news.title}</Link>
</li>
))}
</ol>
);
},
{ label: "News List" },
);
2 changes: 2 additions & 0 deletions demo/site/src/recursivelyLoadBlockData.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BlockLoader, BlockLoaderDependencies, recursivelyLoadBlockData as cometRecursivelyLoadBlockData } from "@comet/cms-site";

import { loader as newsDetailLoader } from "./news/blocks/NewsDetailBlock.loader";
import { loader as newsListLoader } from "./news/blocks/NewsListBlock.loader";

declare module "@comet/cms-site" {
export interface BlockLoaderDependencies {
Expand All @@ -10,6 +11,7 @@ declare module "@comet/cms-site" {

export const blockLoaders: Record<string, BlockLoader> = {
NewsDetail: newsDetailLoader,
NewsList: newsListLoader,
};

//small wrapper for @comet/cms-site recursivelyLoadBlockData that injects blockMeta from block-meta.json
Expand Down
1 change: 1 addition & 0 deletions packages/api/blocks-api/src/blocks-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function extractFromBlockMeta(blockMeta: BlockMetaInterface): BlockMetaField[] {
name: field.name,
kind: field.kind,
nullable: field.nullable,
array: field.array,
};
} else if (field.kind === BlockMetaFieldKind.Enum) {
return {
Expand Down
1 change: 1 addition & 0 deletions packages/api/blocks-api/src/blocks/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export type BlockMetaField =
name: string;
kind: BlockMetaLiteralFieldKind;
nullable: boolean;
array?: boolean;
}
| { name: string; kind: BlockMetaFieldKind.Enum; enum: string[]; nullable: boolean }
| { name: string; kind: BlockMetaFieldKind.Block; block: Block; nullable: boolean }
Expand Down
Loading

0 comments on commit 9e2b0fa

Please sign in to comment.