diff --git a/.changeset/spicy-panthers-bake.md b/.changeset/spicy-panthers-bake.md new file mode 100644 index 0000000000..d3c00e72f5 --- /dev/null +++ b/.changeset/spicy-panthers-bake.md @@ -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[]; +} +``` diff --git a/demo/admin/src/news/blocks/NewsListBlock.tsx b/demo/admin/src/news/blocks/NewsListBlock.tsx new file mode 100644 index 0000000000..4210e8f7f1 --- /dev/null +++ b/demo/admin/src/news/blocks/NewsListBlock.tsx @@ -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 = { + ...createBlockSkeleton(), + name: "NewsList", + displayName: , + defaultValues: () => ({ ids: [] }), + AdminComponent: ({ state, updateState }) => { + const { scope } = useContentScope(); + const dataGridProps = { ...useDataGridRemote(), ...usePersistentColumnState("NewsListBlock") }; + const intl = useIntl(); + + const columns: GridColDef[] = [ + { field: "title", headerName: intl.formatMessage({ id: "news.title", defaultMessage: "Title" }), width: 150 }, + ]; + + const { data, loading, error } = useQuery( + 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 ( + + { + updateState({ ids: newSelection as string[] }); + }} + /> + + ); + }, +}; diff --git a/demo/admin/src/pages/PageContentBlock.tsx b/demo/admin/src/pages/PageContentBlock.tsx index f5f314c790..c49bf0f84f 100644 --- a/demo/admin/src/pages/PageContentBlock.tsx +++ b/demo/admin/src/pages/PageContentBlock.tsx @@ -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"; @@ -32,6 +33,7 @@ export const PageContentBlock = createBlocksBlock({ media: MediaBlock, teaser: TeaserBlock, newsDetail: NewsDetailBlock, + newsList: NewsListBlock, }, additionalItemFields: { ...userGroupAdditionalItemFields, diff --git a/demo/api/block-meta.json b/demo/api/block-meta.json index 4cb2f4a7c3..920d54e29c 100644 --- a/demo/api/block-meta.json +++ b/demo/api/block-meta.json @@ -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": [ @@ -1286,7 +1305,8 @@ "twoLists": "TwoLists", "media": "Media", "teaser": "Teaser", - "newsDetail": "NewsDetail" + "newsDetail": "NewsDetail", + "newsList": "NewsList" }, "nullable": false }, @@ -1342,7 +1362,8 @@ "twoLists": "TwoLists", "media": "Media", "teaser": "Teaser", - "newsDetail": "NewsDetail" + "newsDetail": "NewsDetail", + "newsList": "NewsList" }, "nullable": false }, diff --git a/demo/api/schema.gql b/demo/api/schema.gql index e7f2e064bf..6691f0ee2f 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -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! diff --git a/demo/api/src/news/blocks/news-list.block.ts b/demo/api/src/news/blocks/news-list.block.ts new file mode 100644 index 0000000000..d10a7e5427 --- /dev/null +++ b/demo/api/src/news/blocks/news-list.block.ts @@ -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"); diff --git a/demo/api/src/news/extended-news.resolver.ts b/demo/api/src/news/extended-news.resolver.ts new file mode 100644 index 0000000000..78be9d583b --- /dev/null +++ b/demo/api/src/news/extended-news.resolver.ts @@ -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) {} + + @Query(() => [News]) + @AffectedEntity(News, { idArg: "ids" }) + async newsListByIds(@Args("ids", { type: () => [ID] }) ids: string[]): Promise { + 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)); + } +} diff --git a/demo/api/src/news/news.module.ts b/demo/api/src/news/news.module.ts index 3fe34babc3..52ea944305 100644 --- a/demo/api/src/news/news.module.ts +++ b/demo/api/src/news/news.module.ts @@ -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"; @@ -18,6 +19,7 @@ import { NewsFieldResolver } from "./news-field.resolver"; DependenciesResolverFactory.create(News), DependentsResolverFactory.create(News), NewsLinkBlockTransformerService, + ExtendedNewsResolver, ], exports: [], }) diff --git a/demo/api/src/pages/blocks/page-content.block.ts b/demo/api/src/pages/blocks/page-content.block.ts index e5ef66bac2..640d6d5863 100644 --- a/demo/api/src/pages/blocks/page-content.block.ts +++ b/demo/api/src/pages/blocks/page-content.block.ts @@ -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"; @@ -29,6 +30,7 @@ const supportedBlocks = { media: MediaBlock, teaser: TeaserBlock, newsDetail: NewsDetailBlock, + newsList: NewsListBlock, }; class BlocksBlockItemData extends BaseBlocksBlockItemData(supportedBlocks) { diff --git a/demo/site/src/blocks/PageContentBlock.tsx b/demo/site/src/blocks/PageContentBlock.tsx index d58e2d1232..ef02a9e709 100644 --- a/demo/site/src/blocks/PageContentBlock.tsx +++ b/demo/site/src/blocks/PageContentBlock.tsx @@ -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"; @@ -33,6 +34,7 @@ const supportedBlocks: SupportedBlocks = { twoLists: (props) => , teaser: (props) => , newsDetail: (props) => , + newsList: (props) => , }; export const PageContentBlock = ({ data }: PropsWithData) => { diff --git a/demo/site/src/news/blocks/NewsListBlock.loader.ts b/demo/site/src/news/blocks/NewsListBlock.loader.ts new file mode 100644 index 0000000000..8b173eadcb --- /dev/null +++ b/demo/site/src/news/blocks/NewsListBlock.loader.ts @@ -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 = async ({ blockData, graphQLFetch }): Promise => { + if (blockData.ids.length === 0) { + return []; + } + + const data = await graphQLFetch( + 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; +}; diff --git a/demo/site/src/news/blocks/NewsListBlock.tsx b/demo/site/src/news/blocks/NewsListBlock.tsx new file mode 100644 index 0000000000..291599d2d6 --- /dev/null +++ b/demo/site/src/news/blocks/NewsListBlock.tsx @@ -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) => { + if (newsList.length === 0) { + return null; + } + + return ( +
    + {newsList.map((news) => ( +
  1. + {news.title} +
  2. + ))} +
+ ); + }, + { label: "News List" }, +); diff --git a/demo/site/src/recursivelyLoadBlockData.ts b/demo/site/src/recursivelyLoadBlockData.ts index 763f073682..081b881ffe 100644 --- a/demo/site/src/recursivelyLoadBlockData.ts +++ b/demo/site/src/recursivelyLoadBlockData.ts @@ -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 { @@ -10,6 +11,7 @@ declare module "@comet/cms-site" { export const blockLoaders: Record = { NewsDetail: newsDetailLoader, + NewsList: newsListLoader, }; //small wrapper for @comet/cms-site recursivelyLoadBlockData that injects blockMeta from block-meta.json diff --git a/packages/api/blocks-api/src/blocks-meta.ts b/packages/api/blocks-api/src/blocks-meta.ts index e8a7b1fdff..1eb0627617 100644 --- a/packages/api/blocks-api/src/blocks-meta.ts +++ b/packages/api/blocks-api/src/blocks-meta.ts @@ -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 { diff --git a/packages/api/blocks-api/src/blocks/block.ts b/packages/api/blocks-api/src/blocks/block.ts index 5d969b6d6c..4bbe28de1f 100644 --- a/packages/api/blocks-api/src/blocks/block.ts +++ b/packages/api/blocks-api/src/blocks/block.ts @@ -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 } diff --git a/packages/api/blocks-api/src/blocks/decorators/field.ts b/packages/api/blocks-api/src/blocks/decorators/field.ts index be5148856c..872c78954f 100644 --- a/packages/api/blocks-api/src/blocks/decorators/field.ts +++ b/packages/api/blocks-api/src/blocks/decorators/field.ts @@ -19,8 +19,9 @@ type BlockFieldOptions = nullable?: boolean; } | { - type: "json"; + type: "json" | "string" | "number" | "boolean"; nullable?: boolean; + array?: boolean; } | { nullable?: boolean; @@ -53,6 +54,7 @@ type BlockFieldData = | { kind: BlockMetaLiteralFieldKind; nullable: boolean; + array?: boolean; } | { kind: BlockMetaFieldKind.Enum; enum: string[]; nullable: boolean } | { kind: BlockMetaFieldKind.Block; block: Block; nullable: boolean } @@ -68,13 +70,20 @@ export function getBlockFieldData(ctor: { prototype: any }, propertyKey: string) const fieldType = Reflect.getMetadata(`data:fieldType`, ctor.prototype, propertyKey); const nullable = !!(fieldType && fieldType.nullable); + const array: boolean | undefined = fieldType?.array ?? undefined; if (fieldType && fieldType.type) { - if (fieldType.type === "enum") { + if (fieldType.type === "string") { + ret = { kind: BlockMetaFieldKind.String, nullable, array }; + } else if (fieldType.type === "number") { + ret = { kind: BlockMetaFieldKind.Number, nullable, array }; + } else if (fieldType.type === "boolean") { + ret = { kind: BlockMetaFieldKind.Boolean, nullable, array }; + } else if (fieldType.type === "json") { + ret = { kind: BlockMetaFieldKind.Json, nullable, array }; + } else if (fieldType.type === "enum") { const enumValues = Array.isArray(fieldType.enum) ? fieldType.enum : Object.values(fieldType.enum); ret = { kind: BlockMetaFieldKind.Enum, enum: enumValues, nullable }; - } else if (fieldType.type === "json") { - ret = { kind: BlockMetaFieldKind.Json, nullable }; } else if (fieldType.type === "block") { ret = { kind: BlockMetaFieldKind.Block, block: fieldType.block, nullable }; } else { @@ -110,7 +119,7 @@ export function getBlockFieldData(ctor: { prototype: any }, propertyKey: string) break; case "Array": if (!fieldType || !(isBlockDataInterface(fieldType.prototype) || isBlockInputInterface(fieldType.prototype))) { - throw new Error(`In ${designType.name} for ${propertyKey} only SubBlocks implementing BlockDataInterface are allowed`); + throw new Error(`Unknown array type for ${propertyKey}. An explicit type annotation is necessary.`); } ret = { kind: BlockMetaFieldKind.NestedObjectList, object: fieldType, nullable }; @@ -144,6 +153,7 @@ export class AnnotationBlockMeta implements BlockMetaInterface { name, kind: field.kind, nullable: field.nullable, + array: field.array, }); } else if (field.kind === BlockMetaFieldKind.Enum) { ret.push({ diff --git a/packages/cli/src/commands/generate-block-types.ts b/packages/cli/src/commands/generate-block-types.ts index 9a87e54193..81feef3aaa 100644 --- a/packages/cli/src/commands/generate-block-types.ts +++ b/packages/cli/src/commands/generate-block-types.ts @@ -7,6 +7,7 @@ type BlockMetaField = name: string; kind: "String" | "Number" | "Boolean" | "Json"; nullable: boolean; + array?: boolean; } | { name: string; @@ -48,12 +49,28 @@ let content = ""; function writeFieldType(field: BlockMetaField, blockNamePostfix: string) { if (field.kind === "String") { content += "string"; + + if (field.array) { + content += "[]"; + } } else if (field.kind === "Number") { content += "number"; + + if (field.array) { + content += "[]"; + } } else if (field.kind === "Boolean") { content += "boolean"; + + if (field.array) { + content += "[]"; + } } else if (field.kind === "Json") { content += "unknown"; + + if (field.array) { + content += "[]"; + } } else if (field.kind === "Enum") { content += `"${field.enum.join('" | "')}"`; } else if (field.kind === "Block") {