From 97581cfabd43cbcd4282d085362cb51bad21d18c Mon Sep 17 00:00:00 2001 From: MapleLeaf <19603573+itsMapleLeaf@users.noreply.github.com> Date: Sun, 26 Dec 2021 14:11:17 -0600 Subject: [PATCH] finish embed components --- helpers/convert-object-property-case.test.ts | 37 +++ helpers/convert-object-property-case.ts | 34 +++ library/embed/embed-author.tsx | 30 ++ library/embed/embed-field.tsx | 5 +- library/embed/embed-footer.tsx | 32 ++ library/embed/embed-image.tsx | 23 ++ library/embed/embed-thumbnail.tsx | 23 ++ library/embed/embed.tsx | 37 +-- library/main.ts | 4 + notes.md | 10 +- test/assert-messages.ts | 23 ++ test/embed.test.tsx | 282 ++++++++++++++++++ ...kitchen-sink.test.tsx => reacord.test.tsx} | 25 +- 13 files changed, 514 insertions(+), 51 deletions(-) create mode 100644 helpers/convert-object-property-case.test.ts create mode 100644 helpers/convert-object-property-case.ts create mode 100644 library/embed/embed-author.tsx create mode 100644 library/embed/embed-footer.tsx create mode 100644 library/embed/embed-image.tsx create mode 100644 library/embed/embed-thumbnail.tsx create mode 100644 test/assert-messages.ts create mode 100644 test/embed.test.tsx rename test/{kitchen-sink.test.tsx => reacord.test.tsx} (90%) diff --git a/helpers/convert-object-property-case.test.ts b/helpers/convert-object-property-case.test.ts new file mode 100644 index 0000000..3d4c9b9 --- /dev/null +++ b/helpers/convert-object-property-case.test.ts @@ -0,0 +1,37 @@ +import type { + CamelCasedPropertiesDeep, + SnakeCasedPropertiesDeep, +} from "type-fest" +import { camelCaseDeep, snakeCaseDeep } from "./convert-object-property-case" + +test("camelCaseDeep", () => { + const input = { + some_prop: { + some_deep_prop: "some_deep_value", + }, + someOtherProp: "someOtherValue", + } + + expect(camelCaseDeep(input)).toEqual>({ + someProp: { + someDeepProp: "some_deep_value", + }, + someOtherProp: "someOtherValue", + }) +}) + +test("snakeCaseDeep", () => { + const input = { + someProp: { + someDeepProp: "someDeepValue", + }, + some_other_prop: "someOtherValue", + } + + expect(snakeCaseDeep(input)).toEqual>({ + some_prop: { + some_deep_prop: "someDeepValue", + }, + some_other_prop: "someOtherValue", + }) +}) diff --git a/helpers/convert-object-property-case.ts b/helpers/convert-object-property-case.ts new file mode 100644 index 0000000..0bb2e2a --- /dev/null +++ b/helpers/convert-object-property-case.ts @@ -0,0 +1,34 @@ +import { camelCase, isObject, snakeCase } from "lodash-es" +import type { + CamelCasedPropertiesDeep, + SnakeCasedPropertiesDeep, +} from "type-fest" + +function convertKeyCaseDeep( + input: Input, + convertKey: (key: string) => string, +): Output { + if (!isObject(input)) { + return input as unknown as Output + } + + if (Array.isArray(input)) { + return input.map((item) => + convertKeyCaseDeep(item, convertKey), + ) as unknown as Output + } + + const output: any = {} + for (const [key, value] of Object.entries(input)) { + output[convertKey(key)] = convertKeyCaseDeep(value, convertKey) + } + return output +} + +export function camelCaseDeep(input: T): CamelCasedPropertiesDeep { + return convertKeyCaseDeep(input, camelCase) +} + +export function snakeCaseDeep(input: T): SnakeCasedPropertiesDeep { + return convertKeyCaseDeep(input, snakeCase) +} diff --git a/library/embed/embed-author.tsx b/library/embed/embed-author.tsx new file mode 100644 index 0000000..b6b547d --- /dev/null +++ b/library/embed/embed-author.tsx @@ -0,0 +1,30 @@ +import React from "react" +import { ReacordElement } from "../element.js" +import { EmbedChildNode } from "./embed-child.js" +import type { EmbedOptions } from "./embed-options" + +export type EmbedAuthorProps = { + name?: string + children?: string + url?: string + iconUrl?: string +} + +export function EmbedAuthor(props: EmbedAuthorProps) { + return ( + new EmbedAuthorNode(props)} + /> + ) +} + +class EmbedAuthorNode extends EmbedChildNode { + override modifyEmbedOptions(options: EmbedOptions): void { + options.author = { + name: this.props.name ?? this.props.children ?? "", + url: this.props.url, + icon_url: this.props.iconUrl, + } + } +} diff --git a/library/embed/embed-field.tsx b/library/embed/embed-field.tsx index 766f0e1..f5d07ac 100644 --- a/library/embed/embed-field.tsx +++ b/library/embed/embed-field.tsx @@ -5,8 +5,9 @@ import type { EmbedOptions } from "./embed-options" export type EmbedFieldProps = { name: string + value?: string inline?: boolean - children: string + children?: string } export function EmbedField(props: EmbedFieldProps) { @@ -23,7 +24,7 @@ class EmbedFieldNode extends EmbedChildNode { options.fields ??= [] options.fields.push({ name: this.props.name, - value: this.props.children, + value: this.props.value ?? this.props.children ?? "", inline: this.props.inline, }) } diff --git a/library/embed/embed-footer.tsx b/library/embed/embed-footer.tsx new file mode 100644 index 0000000..fe92ce0 --- /dev/null +++ b/library/embed/embed-footer.tsx @@ -0,0 +1,32 @@ +import React from "react" +import { ReacordElement } from "../element.js" +import { EmbedChildNode } from "./embed-child.js" +import type { EmbedOptions } from "./embed-options" + +export type EmbedFooterProps = { + text?: string + children?: string + iconUrl?: string + timestamp?: string | number | Date +} + +export function EmbedFooter(props: EmbedFooterProps) { + return ( + new EmbedFooterNode(props)} + /> + ) +} + +class EmbedFooterNode extends EmbedChildNode { + override modifyEmbedOptions(options: EmbedOptions): void { + options.footer = { + text: this.props.text ?? this.props.children ?? "", + icon_url: this.props.iconUrl, + } + options.timestamp = this.props.timestamp + ? new Date(this.props.timestamp).toISOString() + : undefined + } +} diff --git a/library/embed/embed-image.tsx b/library/embed/embed-image.tsx new file mode 100644 index 0000000..513d4ec --- /dev/null +++ b/library/embed/embed-image.tsx @@ -0,0 +1,23 @@ +import React from "react" +import { ReacordElement } from "../element.js" +import { EmbedChildNode } from "./embed-child.js" +import type { EmbedOptions } from "./embed-options" + +export type EmbedImageProps = { + url: string +} + +export function EmbedImage(props: EmbedImageProps) { + return ( + new EmbedImageNode(props)} + /> + ) +} + +class EmbedImageNode extends EmbedChildNode { + override modifyEmbedOptions(options: EmbedOptions): void { + options.image = { url: this.props.url } + } +} diff --git a/library/embed/embed-thumbnail.tsx b/library/embed/embed-thumbnail.tsx new file mode 100644 index 0000000..e4b28a0 --- /dev/null +++ b/library/embed/embed-thumbnail.tsx @@ -0,0 +1,23 @@ +import React from "react" +import { ReacordElement } from "../element.js" +import { EmbedChildNode } from "./embed-child.js" +import type { EmbedOptions } from "./embed-options" + +export type EmbedThumbnailProps = { + url: string +} + +export function EmbedThumbnail(props: EmbedThumbnailProps) { + return ( + new EmbedThumbnailNode(props)} + /> + ) +} + +class EmbedThumbnailNode extends EmbedChildNode { + override modifyEmbedOptions(options: EmbedOptions): void { + options.thumbnail = { url: this.props.url } + } +} diff --git a/library/embed/embed.tsx b/library/embed/embed.tsx index c103f5e..0722039 100644 --- a/library/embed/embed.tsx +++ b/library/embed/embed.tsx @@ -1,30 +1,18 @@ import React from "react" +import type { CamelCasedPropertiesDeep } from "type-fest" +import { snakeCaseDeep } from "../../helpers/convert-object-property-case" import { omit } from "../../helpers/omit" import { ReacordElement } from "../element.js" import type { MessageOptions } from "../message" import { Node } from "../node.js" import { EmbedChildNode } from "./embed-child.js" +import type { EmbedOptions } from "./embed-options" -export type EmbedProps = { - description?: string - url?: string - timestamp?: string - color?: number - footer?: { - text: string - iconURL?: string - } - image?: { - url: string - } - thumbnail?: { - url: string - } - author?: { - name: string - url?: string - iconURL?: string - } +export type EmbedProps = Omit< + CamelCasedPropertiesDeep, + "timestamp" +> & { + timestamp?: string | number | Date children?: React.ReactNode } @@ -38,14 +26,19 @@ export function Embed(props: EmbedProps) { class EmbedNode extends Node { override modifyMessageOptions(options: MessageOptions): void { - const embed = omit(this.props, ["children"]) + const embed: EmbedOptions = { + ...snakeCaseDeep(omit(this.props, ["children", "timestamp"])), + timestamp: this.props.timestamp + ? new Date(this.props.timestamp).toISOString() + : undefined, + } + for (const child of this.children) { if (child instanceof EmbedChildNode) { child.modifyEmbedOptions(embed) } } - options.embeds ??= [] options.embeds.push(embed) } } diff --git a/library/main.ts b/library/main.ts index 39de9dd..f4f830b 100644 --- a/library/main.ts +++ b/library/main.ts @@ -3,7 +3,11 @@ export * from "./adapter/discord-js-adapter" export * from "./adapter/test-adapter" export * from "./button" export * from "./embed/embed" +export * from "./embed/embed-author" export * from "./embed/embed-field" +export * from "./embed/embed-footer" +export * from "./embed/embed-image" +export * from "./embed/embed-thumbnail" export * from "./embed/embed-title" export * from "./interaction" export * from "./message" diff --git a/notes.md b/notes.md index 8c354a6..f26b848 100644 --- a/notes.md +++ b/notes.md @@ -6,13 +6,14 @@ - [x] message content - embed - [x] color - - [ ] author + - [x] author - [x] description - [x] title - text children, url - - [ ] footer - icon url, timestamp, text children - - [ ] thumbnail - url - - [ ] image - url + - [x] footer - icon url, timestamp, text children + - [x] thumbnail - url + - [x] image - url - [x] fields - name, value, inline + - [x] test - message components - [x] buttons - [ ] links @@ -22,6 +23,7 @@ - [ ] select onChange - [x] deactivate - [ ] destroy +- [ ] docs # cool ideas / polish diff --git a/test/assert-messages.ts b/test/assert-messages.ts new file mode 100644 index 0000000..b63769f --- /dev/null +++ b/test/assert-messages.ts @@ -0,0 +1,23 @@ +import { nextTick } from "node:process" +import { promisify } from "node:util" +import { omit } from "../helpers/omit" +import type { TestAdapter } from "../library/main" + +const nextTickPromise = promisify(nextTick) + +export async function assertMessages( + adapter: TestAdapter, + expected: ReturnType, +) { + await nextTickPromise() + expect(extractMessageDataSample(adapter)).toEqual(expected) +} + +function extractMessageDataSample(adapter: TestAdapter) { + return adapter.messages.map((message) => ({ + ...message.options, + actionRows: message.options.actionRows.map((row) => + row.map((component) => omit(component, ["customId"])), + ), + })) +} diff --git a/test/embed.test.tsx b/test/embed.test.tsx new file mode 100644 index 0000000..dd9c518 --- /dev/null +++ b/test/embed.test.tsx @@ -0,0 +1,282 @@ +import React from "react" +import { + Embed, + EmbedAuthor, + EmbedField, + EmbedFooter, + EmbedImage, + EmbedThumbnail, + EmbedTitle, + Reacord, + TestAdapter, + TestCommandInteraction, +} from "../library/main" +import { assertMessages } from "./assert-messages" + +const adapter = new TestAdapter() +const reacord = new Reacord({ adapter }) +const reply = reacord.createCommandReply(new TestCommandInteraction(adapter)) + +test("kitchen sink", async () => { + const now = new Date() + + reply.render( + <> + + + title text + description text + + + + + + + , + ) + + await assertMessages(adapter, [ + { + actionRows: [], + content: "", + embeds: [ + { + author: { + icon_url: "https://example.com/author.png", + name: "author", + }, + color: 0xfe_ee_ef, + fields: [ + { + inline: true, + name: "field name", + value: "field value", + }, + { + name: "block field", + value: "block field value", + }, + ], + footer: { + icon_url: "https://example.com/footer.png", + text: "footer text", + }, + image: { + url: "https://example.com/image.png", + }, + thumbnail: { + url: "https://example.com/thumbnail.png", + }, + timestamp: now.toISOString(), + title: "title text", + }, + ], + }, + ]) +}) + +test("author variants", async () => { + reply.render( + <> + + + author name + + + + + + , + ) + + await assertMessages(adapter, [ + { + content: "", + actionRows: [], + embeds: [ + { + author: { + icon_url: "https://example.com/author.png", + name: "author name", + }, + }, + { + author: { + icon_url: "https://example.com/author.png", + name: "", + }, + }, + ], + }, + ]) +}) + +test("field variants", async () => { + reply.render( + <> + + + + + field value + + + + , + ) + + await assertMessages(adapter, [ + { + content: "", + actionRows: [], + embeds: [ + { + fields: [ + { + name: "field name", + value: "field value", + }, + { + inline: true, + name: "field name", + value: "field value", + }, + { + inline: true, + name: "field name", + value: "field value", + }, + { + name: "field name", + value: "", + }, + ], + }, + ], + }, + ]) +}) + +test("footer variants", async () => { + const now = new Date() + reply.render( + <> + + + + + + + + footer text + + + + + , + ) + + await assertMessages(adapter, [ + { + content: "", + actionRows: [], + embeds: [ + { + footer: { + text: "footer text", + }, + }, + { + footer: { + icon_url: "https://example.com/footer.png", + text: "footer text", + }, + }, + { + footer: { + text: "footer text", + }, + timestamp: now.toISOString(), + }, + { + footer: { + icon_url: "https://example.com/footer.png", + text: "", + }, + timestamp: now.toISOString(), + }, + ], + }, + ]) +}) + +test("embed props", async () => { + const now = new Date() + + reply.render( + , + ) + + await assertMessages(adapter, [ + { + content: "", + actionRows: [], + embeds: [ + { + title: "title text", + description: "description text", + url: "https://example.com/", + color: 0xfe_ee_ef, + timestamp: now.toISOString(), + author: { + name: "author name", + url: "https://example.com/author", + icon_url: "https://example.com/author.png", + }, + thumbnail: { url: "https://example.com/thumbnail.png" }, + image: { url: "https://example.com/image.png" }, + footer: { + text: "footer text", + icon_url: "https://example.com/footer.png", + }, + fields: [ + { name: "field name", value: "field value", inline: true }, + { name: "block field", value: "block field value" }, + ], + }, + ], + }, + ]) +}) diff --git a/test/kitchen-sink.test.tsx b/test/reacord.test.tsx similarity index 90% rename from test/kitchen-sink.test.tsx rename to test/reacord.test.tsx index eb4e08c..cbbec72 100644 --- a/test/kitchen-sink.test.tsx +++ b/test/reacord.test.tsx @@ -1,7 +1,4 @@ -import { nextTick } from "node:process" -import { promisify } from "node:util" import * as React from "react" -import { omit } from "../helpers/omit" import { Button, Embed, @@ -11,10 +8,9 @@ import { TestAdapter, TestCommandInteraction, } from "../library/main" +import { assertMessages } from "./assert-messages" -const nextTickPromise = promisify(nextTick) - -test("kitchen-sink", async () => { +test("rendering behavior", async () => { const adapter = new TestAdapter() const reacord = new Reacord({ adapter }) @@ -285,20 +281,3 @@ function KitchenSinkCounter(props: { onDeactivate: () => void }) { ) } - -function extractMessageDataSample(adapter: TestAdapter) { - return adapter.messages.map((message) => ({ - ...message.options, - actionRows: message.options.actionRows.map((row) => - row.map((component) => omit(component, ["customId"])), - ), - })) -} - -async function assertMessages( - adapter: TestAdapter, - expected: ReturnType, -) { - await nextTickPromise() - expect(extractMessageDataSample(adapter)).toEqual(expected) -}