Add useInstance (#6)
This commit is contained in:
@@ -12,7 +12,7 @@ export function pruneNullishValues<T>(input: T): PruneNullishValues<T> {
|
||||
const result: any = {}
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value != undefined) {
|
||||
result[key] = isObject(value) ? pruneNullishValues(value) : value
|
||||
result[key] = pruneNullishValues(value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
20
packages/reacord/library/core/instance-context.tsx
Normal file
20
packages/reacord/library/core/instance-context.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { raise } from "../../helpers/raise"
|
||||
import type { ReacordInstance } from "./instance"
|
||||
|
||||
const Context = React.createContext<ReacordInstance | undefined>(undefined)
|
||||
|
||||
export const InstanceProvider = Context.Provider
|
||||
|
||||
/**
|
||||
* Get the associated instance for the current component.
|
||||
*
|
||||
* @category Core
|
||||
* @see https://reacord.fly.dev/guides/use-instance
|
||||
*/
|
||||
export function useInstance(): ReacordInstance {
|
||||
return (
|
||||
React.useContext(Context) ??
|
||||
raise("Could not find instance, was this component rendered via Reacord?")
|
||||
)
|
||||
}
|
||||
@@ -154,14 +154,16 @@ export class ReacordDiscordJs extends Reacord {
|
||||
// todo please dear god clean this up
|
||||
const channel: ChannelInfo = interaction.channel
|
||||
? {
|
||||
...pick(pruneNullishValues(interaction.channel), [
|
||||
"topic",
|
||||
"nsfw",
|
||||
"lastMessageId",
|
||||
"ownerId",
|
||||
"parentId",
|
||||
"rateLimitPerUser",
|
||||
]),
|
||||
...pruneNullishValues(
|
||||
pick(interaction.channel, [
|
||||
"topic",
|
||||
"nsfw",
|
||||
"lastMessageId",
|
||||
"ownerId",
|
||||
"parentId",
|
||||
"rateLimitPerUser",
|
||||
]),
|
||||
),
|
||||
id: interaction.channelId,
|
||||
}
|
||||
: raise("Non-channel interactions are not supported")
|
||||
@@ -190,15 +192,17 @@ export class ReacordDiscordJs extends Reacord {
|
||||
const member: GuildMemberInfo | undefined =
|
||||
interaction.member instanceof Discord.GuildMember
|
||||
? {
|
||||
...pick(pruneNullishValues(interaction.member), [
|
||||
"id",
|
||||
"nick",
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"displayAvatarUrl",
|
||||
"color",
|
||||
"pending",
|
||||
]),
|
||||
...pruneNullishValues(
|
||||
pick(interaction.member, [
|
||||
"id",
|
||||
"nick",
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"displayAvatarUrl",
|
||||
"color",
|
||||
"pending",
|
||||
]),
|
||||
),
|
||||
displayName: interaction.member.displayName,
|
||||
roles: [...interaction.member.roles.cache.map((role) => role.id)],
|
||||
joinedAt: interaction.member.joinedAt?.toISOString(),
|
||||
@@ -210,18 +214,15 @@ export class ReacordDiscordJs extends Reacord {
|
||||
|
||||
const guild: GuildInfo | undefined = interaction.guild
|
||||
? {
|
||||
...pick(pruneNullishValues(interaction.guild), ["id", "name"]),
|
||||
...pruneNullishValues(pick(interaction.guild, ["id", "name"])),
|
||||
member: member ?? raise("unexpected: member is undefined"),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const user: UserInfo = {
|
||||
...pick(pruneNullishValues(interaction.user), [
|
||||
"id",
|
||||
"username",
|
||||
"discriminator",
|
||||
"tag",
|
||||
]),
|
||||
...pruneNullishValues(
|
||||
pick(interaction.user, ["id", "username", "discriminator", "tag"]),
|
||||
),
|
||||
avatarUrl: interaction.user.avatarURL()!,
|
||||
accentColor: interaction.user.accentColor ?? undefined,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ReactNode } from "react"
|
||||
import React from "react"
|
||||
import type { ComponentInteraction } from "../internal/interaction"
|
||||
import { reconciler } from "../internal/reconciler.js"
|
||||
import type { Renderer } from "../internal/renderers/renderer"
|
||||
import type { ReacordInstance } from "./instance"
|
||||
import { InstanceProvider } from "./instance-context"
|
||||
|
||||
/**
|
||||
* @category Core
|
||||
@@ -47,13 +49,12 @@ export abstract class Reacord {
|
||||
|
||||
const container = reconciler.createContainer(renderer, 0, false, {})
|
||||
|
||||
if (initialContent !== undefined) {
|
||||
reconciler.updateContainer(initialContent, container)
|
||||
}
|
||||
|
||||
return {
|
||||
const instance: ReacordInstance = {
|
||||
render: (content: ReactNode) => {
|
||||
reconciler.updateContainer(content, container)
|
||||
reconciler.updateContainer(
|
||||
<InstanceProvider value={instance}>{content}</InstanceProvider>,
|
||||
container,
|
||||
)
|
||||
},
|
||||
deactivate: () => {
|
||||
this.deactivate(renderer)
|
||||
@@ -63,6 +64,12 @@ export abstract class Reacord {
|
||||
renderer.destroy()
|
||||
},
|
||||
}
|
||||
|
||||
if (initialContent !== undefined) {
|
||||
instance.render(initialContent)
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
private deactivate(renderer: Renderer) {
|
||||
@@ -13,5 +13,6 @@ export * from "./core/components/link"
|
||||
export * from "./core/components/option"
|
||||
export * from "./core/components/select"
|
||||
export * from "./core/instance"
|
||||
export { useInstance } from "./core/instance-context"
|
||||
export * from "./core/reacord"
|
||||
export * from "./core/reacord-discord-js"
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
"test": "vitest --coverage --no-watch",
|
||||
"test-dev": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"playground": "nodemon --exec esmo --ext ts,tsx ./playground/main.tsx",
|
||||
"playground": "nodemon --exec esmo --ext ts,tsx --inspect=5858 --enable-source-maps ./playground/main.tsx",
|
||||
"release": "release-it"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-reconciler": "^0.26.4",
|
||||
"nanoid": "^3.1.30",
|
||||
"nanoid": "^3.1.31",
|
||||
"react-reconciler": "^0.26.2",
|
||||
"rxjs": "^7.5.2"
|
||||
},
|
||||
@@ -47,7 +47,7 @@
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"c8": "^7.11.0",
|
||||
"discord.js": "^13.5.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"dotenv": "^11.0.0",
|
||||
"esbuild": "latest",
|
||||
"esbuild-jest": "^0.5.0",
|
||||
"esmo": "^0.13.0",
|
||||
@@ -61,7 +61,7 @@
|
||||
"type-fest": "^2.9.0",
|
||||
"typescript": "^4.5.4",
|
||||
"vite": "^2.7.10",
|
||||
"vitest": "^0.0.140"
|
||||
"vitest": "^0.0.141"
|
||||
},
|
||||
"resolutions": {
|
||||
"esbuild": "latest"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Client } from "discord.js"
|
||||
import "dotenv/config"
|
||||
import React from "react"
|
||||
import { Button, ReacordDiscordJs } from "../library/main"
|
||||
import { Button, ReacordDiscordJs, useInstance } from "../library/main"
|
||||
import { createCommandHandler } from "./command-handler"
|
||||
import { Counter } from "./counter"
|
||||
import { FruitSelect } from "./fruit-select"
|
||||
@@ -93,6 +93,17 @@ createCommandHandler(client, [
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete-this",
|
||||
description: "delete this",
|
||||
run: (interaction) => {
|
||||
function DeleteThis() {
|
||||
const instance = useInstance()
|
||||
return <Button label="delete this" onClick={() => instance.destroy()} />
|
||||
}
|
||||
reacord.reply(interaction, <DeleteThis />)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
await client.login(process.env.TEST_BOT_TOKEN)
|
||||
|
||||
@@ -37,6 +37,8 @@ import { InteractionReplyRenderer } from "../library/internal/renderers/interact
|
||||
|
||||
const nextTickPromise = promisify(nextTick)
|
||||
|
||||
export type MessageSample = ReturnType<ReacordTester["sampleMessages"]>[0]
|
||||
|
||||
/**
|
||||
* A Record adapter for automated tests. WIP
|
||||
*/
|
||||
@@ -51,33 +53,32 @@ export class ReacordTester extends Reacord {
|
||||
return [...this.messageContainer]
|
||||
}
|
||||
|
||||
override send(): ReacordInstance {
|
||||
override send(initialContent?: ReactNode): ReacordInstance {
|
||||
return this.createInstance(
|
||||
new ChannelMessageRenderer(new TestChannel(this.messageContainer)),
|
||||
initialContent,
|
||||
)
|
||||
}
|
||||
|
||||
override reply(): ReacordInstance {
|
||||
override reply(initialContent?: ReactNode): ReacordInstance {
|
||||
return this.createInstance(
|
||||
new InteractionReplyRenderer(
|
||||
new TestCommandInteraction(this.messageContainer),
|
||||
),
|
||||
initialContent,
|
||||
)
|
||||
}
|
||||
|
||||
override ephemeralReply(): ReacordInstance {
|
||||
return this.reply()
|
||||
override ephemeralReply(initialContent?: ReactNode): ReacordInstance {
|
||||
return this.reply(initialContent)
|
||||
}
|
||||
|
||||
async assertMessages(expected: ReturnType<this["sampleMessages"]>) {
|
||||
async assertMessages(expected: MessageSample[]) {
|
||||
await nextTickPromise()
|
||||
expect(this.sampleMessages()).toEqual(expected)
|
||||
}
|
||||
|
||||
async assertRender(
|
||||
content: ReactNode,
|
||||
expected: ReturnType<this["sampleMessages"]>,
|
||||
) {
|
||||
async assertRender(content: ReactNode, expected: MessageSample[]) {
|
||||
const instance = this.reply()
|
||||
instance.render(content)
|
||||
await this.assertMessages(expected)
|
||||
@@ -274,11 +275,11 @@ class TestComponentEvent {
|
||||
guild: GuildInfo = {} as any // todo
|
||||
|
||||
reply(content?: ReactNode): ReacordInstance {
|
||||
return this.tester.reply()
|
||||
return this.tester.reply(content)
|
||||
}
|
||||
|
||||
ephemeralReply(content?: ReactNode): ReacordInstance {
|
||||
return this.tester.ephemeralReply()
|
||||
return this.tester.ephemeralReply(content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
72
packages/reacord/test/use-instance.test.tsx
Normal file
72
packages/reacord/test/use-instance.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { ReacordInstance } from "../library/main"
|
||||
import { Button, useInstance } from "../library/main"
|
||||
import type { MessageSample } from "./test-adapter"
|
||||
import { ReacordTester } from "./test-adapter"
|
||||
|
||||
describe("useInstance", () => {
|
||||
it("returns the instance of itself", async () => {
|
||||
let instanceFromHook: ReacordInstance | undefined
|
||||
|
||||
function TestComponent({ name }: { name: string }) {
|
||||
const instance = useInstance()
|
||||
instanceFromHook ??= instance
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={`create ${name}`}
|
||||
onClick={(event) => {
|
||||
event.reply(<TestComponent name="child" />)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label={`destroy ${name}`}
|
||||
onClick={() => instance.destroy()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function messageOutput(name: string): MessageSample {
|
||||
return {
|
||||
content: "",
|
||||
embeds: [],
|
||||
actionRows: [
|
||||
[
|
||||
{
|
||||
type: "button",
|
||||
label: `create ${name}`,
|
||||
style: "secondary",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
label: `destroy ${name}`,
|
||||
style: "secondary",
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const tester = new ReacordTester()
|
||||
const instance = tester.send(<TestComponent name="parent" />)
|
||||
|
||||
await tester.assertMessages([messageOutput("parent")])
|
||||
expect(instanceFromHook).toBe(instance)
|
||||
|
||||
tester.findButtonByLabel("create parent").click()
|
||||
await tester.assertMessages([
|
||||
messageOutput("parent"),
|
||||
messageOutput("child"),
|
||||
])
|
||||
|
||||
// this test ensures that the only the child instance is destroyed,
|
||||
// and not the parent instance
|
||||
tester.findButtonByLabel("destroy child").click()
|
||||
await tester.assertMessages([messageOutput("parent")])
|
||||
|
||||
tester.findButtonByLabel("destroy parent").click()
|
||||
await tester.assertMessages([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user