diff --git a/.changeset/fifty-lemons-work.md b/.changeset/fifty-lemons-work.md new file mode 100644 index 0000000000..3d4ae3fb3e --- /dev/null +++ b/.changeset/fifty-lemons-work.md @@ -0,0 +1,8 @@ +--- +"@comet/cms-admin": minor +"@comet/blocks-api": minor +"@comet/cms-site": minor +"@comet/cms-api": minor +--- + +Add PhoneLinkBlock and EmailLinkBlock diff --git a/demo/admin/src/common/blocks/LinkBlock.tsx b/demo/admin/src/common/blocks/LinkBlock.tsx index c81fcff1a7..3e6ac3173f 100644 --- a/demo/admin/src/common/blocks/LinkBlock.tsx +++ b/demo/admin/src/common/blocks/LinkBlock.tsx @@ -1,4 +1,4 @@ -import { createLinkBlock, DamFileDownloadLinkBlock, ExternalLinkBlock, InternalLinkBlock } from "@comet/cms-admin"; +import { createLinkBlock, DamFileDownloadLinkBlock, EmailLinkBlock, ExternalLinkBlock, InternalLinkBlock, PhoneLinkBlock } from "@comet/cms-admin"; import { NewsLinkBlock } from "@src/news/blocks/NewsLinkBlock"; export const LinkBlock = createLinkBlock({ @@ -7,5 +7,7 @@ export const LinkBlock = createLinkBlock({ external: ExternalLinkBlock, news: NewsLinkBlock, damFileDownload: DamFileDownloadLinkBlock, + email: EmailLinkBlock, + phone: PhoneLinkBlock, }, }); diff --git a/demo/api/block-meta.json b/demo/api/block-meta.json index 225dacfe22..87afb21b36 100644 --- a/demo/api/block-meta.json +++ b/demo/api/block-meta.json @@ -448,6 +448,23 @@ } ] }, + { + "name": "EmailLink", + "fields": [ + { + "name": "email", + "kind": "String", + "nullable": true + } + ], + "inputFields": [ + { + "name": "email", + "kind": "String", + "nullable": true + } + ] + }, { "name": "ExternalLink", "fields": [ @@ -796,7 +813,9 @@ "internal": "InternalLink", "external": "ExternalLink", "news": "NewsLink", - "damFileDownload": "DamFileDownloadLink" + "damFileDownload": "DamFileDownloadLink", + "phone": "PhoneLink", + "email": "EmailLink" }, "nullable": false } @@ -826,7 +845,9 @@ "internal": "InternalLink", "external": "ExternalLink", "news": "NewsLink", - "damFileDownload": "DamFileDownloadLink" + "damFileDownload": "DamFileDownloadLink", + "phone": "PhoneLink", + "email": "EmailLink" }, "nullable": false } @@ -1259,6 +1280,23 @@ } ] }, + { + "name": "PhoneLink", + "fields": [ + { + "name": "phone", + "kind": "String", + "nullable": false + } + ], + "inputFields": [ + { + "name": "phone", + "kind": "String", + "nullable": false + } + ] + }, { "name": "PixelImage", "fields": [ @@ -2148,7 +2186,7 @@ { "name": "youtubeIdentifier", "kind": "String", - "nullable": false + "nullable": true }, { "name": "aspectRatio", diff --git a/demo/api/src/common/blocks/linkBlock/link.block.ts b/demo/api/src/common/blocks/linkBlock/link.block.ts index 5df9b0eb2c..f1b4163feb 100644 --- a/demo/api/src/common/blocks/linkBlock/link.block.ts +++ b/demo/api/src/common/blocks/linkBlock/link.block.ts @@ -1,7 +1,14 @@ -import { ExternalLinkBlock } from "@comet/blocks-api"; +import { EmailLinkBlock, ExternalLinkBlock, PhoneLinkBlock } from "@comet/blocks-api"; import { createLinkBlock, DamFileDownloadLinkBlock, InternalLinkBlock } from "@comet/cms-api"; import { NewsLinkBlock } from "@src/news/blocks/news-link.block"; export const LinkBlock = createLinkBlock({ - supportedBlocks: { internal: InternalLinkBlock, external: ExternalLinkBlock, news: NewsLinkBlock, damFileDownload: DamFileDownloadLinkBlock }, + supportedBlocks: { + internal: InternalLinkBlock, + external: ExternalLinkBlock, + news: NewsLinkBlock, + damFileDownload: DamFileDownloadLinkBlock, + phone: PhoneLinkBlock, + email: EmailLinkBlock, + }, }); diff --git a/demo/site/src/blocks/LinkBlock.tsx b/demo/site/src/blocks/LinkBlock.tsx index ba7a379d5b..f24a3bdc45 100644 --- a/demo/site/src/blocks/LinkBlock.tsx +++ b/demo/site/src/blocks/LinkBlock.tsx @@ -1,8 +1,10 @@ import { DamFileDownloadLinkBlock, + EmailLinkBlock, ExternalLinkBlock, InternalLinkBlock, OneOfBlock, + PhoneLinkBlock, PropsWithData, SupportedBlocks, withPreview, @@ -32,6 +34,16 @@ const supportedBlocks: SupportedBlocks = { {children} ), + email: ({ children, title, ...props }) => ( + + {children} + + ), + phone: ({ children, title, ...props }) => ( + + {children} + + ), }; interface LinkBlockProps extends PropsWithData { diff --git a/packages/admin/cms-admin/src/blocks/EmailLinkBlock.tsx b/packages/admin/cms-admin/src/blocks/EmailLinkBlock.tsx new file mode 100644 index 0000000000..4f22207523 --- /dev/null +++ b/packages/admin/cms-admin/src/blocks/EmailLinkBlock.tsx @@ -0,0 +1,80 @@ +import { Field, FinalFormInput } from "@comet/admin"; +import { BlockCategory, BlockInterface, BlocksFinalForm, createBlockSkeleton, SelectPreviewComponent } from "@comet/blocks-admin"; +import * as React from "react"; +import { FormattedMessage } from "react-intl"; + +interface EmailLinkBlockData { + email?: string; +} + +interface EmailLinkBlockInput { + email?: string; +} + +export const EmailLinkBlock: BlockInterface = { + ...createBlockSkeleton(), + + name: "Email", + + displayName: , + + defaultValues: () => ({ email: undefined }), + + category: BlockCategory.Navigation, + + input2State: (state) => { + return state; + }, + + state2Output: (state) => { + return { + email: state.email, + }; + }, + + output2State: async (output) => { + return { + email: output.email, + }; + }, + + isValid: (state) => { + return state.email ? isEmail(state.email) : true; + }, + + AdminComponent: ({ state, updateState }) => { + return ( + + { + updateState((prevState) => ({ ...prevState, ...newState })); + }} + initialValues={state} + > + } + name="email" + component={FinalFormInput} + fullWidth + validate={(email: string) => { + if (email && !isEmail(email)) { + return ; + } + }} + /> + + + ); + }, + previewContent: (state) => { + return state.email ? [{ type: "text", content: state.email }] : []; + }, +}; + +const isEmail = (text: string) => { + return !!String(text) + .toLowerCase() + .match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ); +}; diff --git a/packages/admin/cms-admin/src/blocks/PhoneLinkBlock.tsx b/packages/admin/cms-admin/src/blocks/PhoneLinkBlock.tsx new file mode 100644 index 0000000000..e9cc596f24 --- /dev/null +++ b/packages/admin/cms-admin/src/blocks/PhoneLinkBlock.tsx @@ -0,0 +1,76 @@ +import { Field, FinalFormInput } from "@comet/admin"; +import { BlockCategory, BlockInterface, BlocksFinalForm, createBlockSkeleton, SelectPreviewComponent } from "@comet/blocks-admin"; +import * as React from "react"; +import { FormattedMessage } from "react-intl"; + +interface PhoneLinkBlockData { + phone: string; +} + +interface PhoneLinkBlockInput { + phone: string; +} + +export const PhoneLinkBlock: BlockInterface = { + ...createBlockSkeleton(), + + name: "Phone", + + displayName: , + + defaultValues: () => ({ phone: "" }), + + category: BlockCategory.Navigation, + + input2State: (state) => { + return state; + }, + + state2Output: (state) => { + return { + phone: state.phone, + }; + }, + + output2State: async (output) => { + return { + phone: output.phone, + }; + }, + + isValid: (state) => { + return state.phone ? isPhone(state.phone) : true; + }, + + AdminComponent: ({ state, updateState }) => { + return ( + + { + updateState((prevState) => ({ ...prevState, ...newState })); + }} + initialValues={state} + > + } + name="phone" + component={FinalFormInput} + fullWidth + validate={(phone: string) => { + if (phone && !isPhone(phone)) { + return ; + } + }} + /> + + + ); + }, + previewContent: (state) => { + return state.phone ? [{ type: "text", content: state.phone }] : []; + }, +}; + +const isPhone = (text: string) => { + return !!String(text).match(/^[+]?[0-9]{4,20}$/im); +}; diff --git a/packages/admin/cms-admin/src/index.ts b/packages/admin/cms-admin/src/index.ts index 512dd1d371..08d8b0c16b 100644 --- a/packages/admin/cms-admin/src/index.ts +++ b/packages/admin/cms-admin/src/index.ts @@ -9,9 +9,11 @@ export type { TextImageBlockFactoryOptions } from "./blocks/createTextImageBlock export { createTextImageBlock } from "./blocks/createTextImageBlock"; export { createTextLinkBlock } from "./blocks/createTextLinkBlock"; export { DamVideoBlock } from "./blocks/DamVideoBlock"; +export { EmailLinkBlock } from "./blocks/EmailLinkBlock"; export { ExternalLinkBlock } from "./blocks/ExternalLinkBlock"; export { EditImageDialog } from "./blocks/image/EditImageDialog"; export { InternalLinkBlock } from "./blocks/InternalLinkBlock"; +export { PhoneLinkBlock } from "./blocks/PhoneLinkBlock"; export { PixelImageBlock } from "./blocks/PixelImageBlock"; export { SvgImageBlock } from "./blocks/SvgImageBlock"; export { useCmsBlockContext } from "./blocks/useCmsBlockContext"; diff --git a/packages/api/blocks-api/block-meta.json b/packages/api/blocks-api/block-meta.json index cc8f39e262..e90e7b04c1 100644 --- a/packages/api/blocks-api/block-meta.json +++ b/packages/api/blocks-api/block-meta.json @@ -1,4 +1,21 @@ [ + { + "name": "EmailLink", + "fields": [ + { + "name": "email", + "kind": "String", + "nullable": true + } + ], + "inputFields": [ + { + "name": "email", + "kind": "String", + "nullable": true + } + ] + }, { "name": "ExternalLink", "fields": [ @@ -26,6 +43,23 @@ } ] }, + { + "name": "PhoneLink", + "fields": [ + { + "name": "phone", + "kind": "String", + "nullable": false + } + ], + "inputFields": [ + { + "name": "phone", + "kind": "String", + "nullable": false + } + ] + }, { "name": "Space", "fields": [ diff --git a/packages/api/blocks-api/src/blocks/email-link.block.ts b/packages/api/blocks-api/src/blocks/email-link.block.ts new file mode 100644 index 0000000000..fdf93ed01d --- /dev/null +++ b/packages/api/blocks-api/src/blocks/email-link.block.ts @@ -0,0 +1,22 @@ +import { IsEmail, IsOptional } from "class-validator"; + +import { BlockData, BlockInput, createBlock, inputToData } from "./block"; +import { BlockField } from "./decorators/field"; + +class EmailLinkBlockData extends BlockData { + @BlockField({ nullable: true }) + email?: string; +} + +class EmailLinkBlockInput extends BlockInput { + @IsOptional() + @IsEmail() + @BlockField({ nullable: true }) + email?: string; + + transformToBlockData(): EmailLinkBlockData { + return inputToData(EmailLinkBlockData, this); + } +} + +export const EmailLinkBlock = createBlock(EmailLinkBlockData, EmailLinkBlockInput, "EmailLink"); diff --git a/packages/api/blocks-api/src/blocks/phone-link.block.ts b/packages/api/blocks-api/src/blocks/phone-link.block.ts new file mode 100644 index 0000000000..fae94b243c --- /dev/null +++ b/packages/api/blocks-api/src/blocks/phone-link.block.ts @@ -0,0 +1,21 @@ +import { IsString } from "class-validator"; + +import { BlockData, BlockInput, createBlock, inputToData } from "./block"; +import { BlockField } from "./decorators/field"; + +class PhoneLinkBlockData extends BlockData { + @BlockField() + phone: string; +} + +class PhoneLinkBlockInput extends BlockInput { + @IsString() + @BlockField() + phone: string; + + transformToBlockData(): PhoneLinkBlockData { + return inputToData(PhoneLinkBlockData, this); + } +} + +export const PhoneLinkBlock = createBlock(PhoneLinkBlockData, PhoneLinkBlockInput, "PhoneLink"); diff --git a/packages/api/blocks-api/src/index.ts b/packages/api/blocks-api/src/index.ts index 4e2024b2b1..16b20a8503 100644 --- a/packages/api/blocks-api/src/index.ts +++ b/packages/api/blocks-api/src/index.ts @@ -39,6 +39,7 @@ export { AnnotationBlockMeta, BlockField, getFieldKeys } from "./blocks/decorato export { RootBlock } from "./blocks/decorators/root-block"; export { RootBlockEntity } from "./blocks/decorators/root-block-entity"; export { TransformDependencies } from "./blocks/dependencies"; +export { EmailLinkBlock } from "./blocks/email-link.block"; export { ExternalLinkBlock } from "./blocks/ExternalLinkBlock"; export { ColumnsBlockFactory } from "./blocks/factories/columns-block.factory"; export { @@ -63,6 +64,7 @@ export { getMostSignificantPreviewImageUrlTemplate, getPreviewImageUrlTemplates export { composeBlocks } from "./blocks/helpers/composeBlocks"; export { strictBlockDataFactoryDecorator } from "./blocks/helpers/strictBlockDataFactoryDecorator"; export { strictBlockInputFactoryDecorator } from "./blocks/helpers/strictBlockInputFactoryDecorator"; +export { PhoneLinkBlock } from "./blocks/phone-link.block"; export { SpaceBlock } from "./blocks/SpaceBlock/SpaceBlock"; export { transformToSaveIndex } from "./blocks/transformToSaveIndex/transformToSaveIndex"; export { YouTubeVideoBlock } from "./blocks/youtube-video.block"; diff --git a/packages/api/cms-api/block-meta.json b/packages/api/cms-api/block-meta.json index 7dfdc23a4c..70bc695918 100644 --- a/packages/api/cms-api/block-meta.json +++ b/packages/api/cms-api/block-meta.json @@ -234,6 +234,23 @@ } ] }, + { + "name": "EmailLink", + "fields": [ + { + "name": "email", + "kind": "String", + "nullable": true + } + ], + "inputFields": [ + { + "name": "email", + "kind": "String", + "nullable": true + } + ] + }, { "name": "ExternalLink", "fields": [ @@ -335,7 +352,9 @@ "kind": "OneOfBlocks", "blocks": { "internal": "InternalLink", - "external": "ExternalLink" + "external": "ExternalLink", + "email": "EmailLink", + "phone": "PhoneLink" }, "nullable": false } @@ -363,7 +382,9 @@ "kind": "OneOfBlocks", "blocks": { "internal": "InternalLink", - "external": "ExternalLink" + "external": "ExternalLink", + "email": "EmailLink", + "phone": "PhoneLink" }, "nullable": false } @@ -414,6 +435,23 @@ } ] }, + { + "name": "PhoneLink", + "fields": [ + { + "name": "phone", + "kind": "String", + "nullable": false + } + ], + "inputFields": [ + { + "name": "phone", + "kind": "String", + "nullable": false + } + ] + }, { "name": "PixelImage", "fields": [ diff --git a/packages/api/cms-api/generate-block-meta.ts b/packages/api/cms-api/generate-block-meta.ts index d2d28118f3..52921fb356 100644 --- a/packages/api/cms-api/generate-block-meta.ts +++ b/packages/api/cms-api/generate-block-meta.ts @@ -1,4 +1,4 @@ -import { createRichTextBlock, createTextLinkBlock, ExternalLinkBlock, getBlocksMeta } from "@comet/blocks-api"; +import { createRichTextBlock, createTextLinkBlock, EmailLinkBlock, ExternalLinkBlock, getBlocksMeta, PhoneLinkBlock } from "@comet/blocks-api"; import { promises as fs } from "fs"; import { createLinkBlock, createSeoBlock, createTextImageBlock, InternalLinkBlock } from "./src"; @@ -6,7 +6,9 @@ import { createLinkBlock, createSeoBlock, createTextImageBlock, InternalLinkBloc async function generateBlockMeta(): Promise { console.info("Generating block-meta.json..."); - const LinkBlock = createLinkBlock({ supportedBlocks: { internal: InternalLinkBlock, external: ExternalLinkBlock } }); + const LinkBlock = createLinkBlock({ + supportedBlocks: { internal: InternalLinkBlock, external: ExternalLinkBlock, email: EmailLinkBlock, phone: PhoneLinkBlock }, + }); const TextBlock = createRichTextBlock({ link: LinkBlock }); diff --git a/packages/site/cms-site/src/blocks/EmailLinkBlock.tsx b/packages/site/cms-site/src/blocks/EmailLinkBlock.tsx new file mode 100644 index 0000000000..1b873564bb --- /dev/null +++ b/packages/site/cms-site/src/blocks/EmailLinkBlock.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { EmailLinkBlockData } from "../blocks.generated"; +import { PropsWithData } from "./PropsWithData"; + +interface EmailLinkBlockProps extends PropsWithData { + children: React.ReactElement; + title?: string; +} + +export const EmailLinkBlock = ({ data: { email }, children, title }: EmailLinkBlockProps) => { + if (!email) { + return children; + } + + const childProps = { + href: `mailto:${email}`, + title, + }; + + return React.cloneElement(children, childProps); +}; diff --git a/packages/site/cms-site/src/blocks/PhoneLinkBlock.tsx b/packages/site/cms-site/src/blocks/PhoneLinkBlock.tsx new file mode 100644 index 0000000000..d5e0251044 --- /dev/null +++ b/packages/site/cms-site/src/blocks/PhoneLinkBlock.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { PhoneLinkBlockData } from "../blocks.generated"; +import { PropsWithData } from "./PropsWithData"; + +interface PhoneLinkBlockProps extends PropsWithData { + children: React.ReactElement; + title?: string; +} + +export const PhoneLinkBlock = ({ data: { phone }, children, title }: PhoneLinkBlockProps) => { + if (!phone) { + return children; + } + + const childProps = { + href: `tel:${phone}`, + title, + }; + + return React.cloneElement(children, childProps); +}; diff --git a/packages/site/cms-site/src/index.ts b/packages/site/cms-site/src/index.ts index 927e1a6027..e1d0602063 100644 --- a/packages/site/cms-site/src/index.ts +++ b/packages/site/cms-site/src/index.ts @@ -1,4 +1,5 @@ export { DamFileDownloadLinkBlock } from "./blocks/DamFileDownloadLinkBlock"; +export { EmailLinkBlock } from "./blocks/EmailLinkBlock"; export { ExternalLinkBlock } from "./blocks/ExternalLinkBlock"; export { BlocksBlock } from "./blocks/factories/BlocksBlock"; export { ListBlock } from "./blocks/factories/ListBlock"; @@ -7,6 +8,7 @@ export { OptionalBlock } from "./blocks/factories/OptionalBlock"; export { SeoBlock } from "./blocks/factories/SeoBlock"; export type { SupportedBlocks } from "./blocks/factories/types"; export { InternalLinkBlock } from "./blocks/InternalLinkBlock"; +export { PhoneLinkBlock } from "./blocks/PhoneLinkBlock"; export { PixelImageBlock } from "./blocks/PixelImageBlock"; export type { PropsWithData } from "./blocks/PropsWithData"; export { hasRichTextBlockContent } from "./blocks/RichTextBlock";