Add useInstance (#6)

This commit is contained in:
Darius
2022-01-11 16:39:35 -06:00
committed by MapleLeaf
parent 2851d4b787
commit 26eb8bedb0
12 changed files with 230 additions and 89 deletions

View File

@@ -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

View 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?")
)
}

View File

@@ -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,
}

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)
}
}

View 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([])
})
})

View File

@@ -0,0 +1,26 @@
---
meta:
title: useInstance
description: Using useInstance to get the current instance within a component
---
# useInstance
You can use `useInstance` to get the current [instance](/guides/sending-messages) within a component. This can be used to let a component destroy or deactivate itself.
```jsx
import { Button, useInstance } from "reacord"
function SelfDestruct() {
const instance = useInstance()
return (
<Button
style="danger"
label="delete this"
onClick={() => instance.destroy()}
/>
)
}
reacord.send(channelId, <SelfDestruct />)
```

View File

@@ -25,7 +25,7 @@
"react-dom": "^17.0.2",
"remix": "^1.1.1",
"remix-tailwind": "^0.2.1",
"tailwindcss": "^3.0.12"
"tailwindcss": "^3.0.13"
},
"devDependencies": {
"@remix-run/dev": "^1.1.1",
@@ -39,7 +39,7 @@
"concurrently": "^7.0.0",
"cypress": "^9.2.1",
"execa": "^6.0.0",
"rehype-prism-plus": "^1.3.0",
"rehype-prism-plus": "^1.3.1",
"typedoc": "^0.22.10",
"typescript": "^4.5.4",
"wait-on": "^6.0.0"