Skip to content

Commit

Permalink
(feat): Allow sharing of main app's context with toast app (#290)
Browse files Browse the repository at this point in the history
* (feat): Allow sharing of main app's context with toast app

* Update src/ts/interface.ts
  • Loading branch information
Maronato authored Oct 18, 2021
1 parent e82c89b commit 736c2ba
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 8 deletions.
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ If you are using Vue 2, check out [Vue Toastification v1](https://github.com/Mar
- [Close the toast using a custom component](#close-the-toast-using-a-custom-component)
- [Render a JSX component](#render-a-jsx-component)
- [Render a component with props and events](#render-a-component-with-props-and-events)
- [Access global components and plugins inside toasts](#access-global-components-and-plugins-inside-toasts)
- [Dismiss toasts programmatically](#dismiss-toasts-programmatically)
- [Update toasts programmatically](#update-toasts-programmatically)
- [Clear all toasts](#clear-all-toasts)
Expand Down Expand Up @@ -381,6 +382,21 @@ const content = {
toast(content);
```

#### Access global components and plugins inside toasts
When building custom toast components, it may be useful to access the context of your main app to use stuff that is shared globally inside it. These include things like:
- global components such as `RouterLink`, `NuxtLink`, etc
- global state and properties
- custom directives
- custom mixins
- data from other plugins

To give Vue Toastification access to your app's context, you can set `shareAppContext` to `true` during registration.
```ts
app.use(Toast, {
shareAppContext: true,
});
```

### Dismiss toasts programmatically
When a toast is created, an ID is assigned to it. You can use it later to programmatically dismiss the toast.

Expand Down Expand Up @@ -449,7 +465,7 @@ toast("my toast", {
```
```css
<style>
/* When setting CSS, remember that priority increases with specificity, so don't forget to select the exisiting classes as well */
/* When setting CSS, remember that priority increases with specificity, so don't forget to select the existing classes as well */

.Vue-Toastification__toast--default.my-custom-toast-class {
background-color: red;
Expand Down Expand Up @@ -482,7 +498,7 @@ app.use(Toast, {
```
```css
<style>
/* When setting CSS, remember that priority increases with specificity, so don't forget to select the exisiting classes as well */
/* When setting CSS, remember that priority increases with specificity, so don't forget to select the existing classes as well */

/* This will only affect the top-right container */
.Vue-Toastification__container.top-right.my-container-class{
Expand Down Expand Up @@ -973,6 +989,7 @@ Sometimes you may need to create a new Vue Toastification instance and make it a
| accessibility | `{ toastRole?: string; closeButtonLabel?: string }` | `{ toastRole: "alert", closeButtonLabel: "close" }` | Accessibility options. Define the `role` attribute of the toast body and the `aria-label` attribute of the close button. |
| rtl | Boolean | `false` | Enables Right to Left layout. |
| eventBus | EventBus instance | auto-generated | EventBus instance used to pass events between the interface and the plugin instance. |
| shareAppContext | Boolean or App instance | `false` | Whether or not to share your main app's context with Vue Toastification. |

### Toast (toast)
| Parameter | Type | Required | Description |
Expand Down
12 changes: 9 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Plugin, InjectionKey, provide, inject, getCurrentInstance } from "vue"
import { buildInterface } from "./ts/interface"
import type { ToastInterface } from "./ts/interface"
import { POSITION, TYPE } from "./ts/constants"
import { POSITION, TYPE, VT_NAMESPACE } from "./ts/constants"
import { EventBusInterface, isEventBusInterface, EventBus } from "./ts/eventBus"
import type { PluginOptions } from "./types"
import * as ownExports from "./index"
Expand All @@ -10,7 +10,7 @@ import { isBrowser } from "./ts/utils"

const createMockToastInterface = (): ToastInterface => {
const toast = () =>
console.warn("[Vue Toastification] This plugin does not support SSR!")
console.warn(`[${VT_NAMESPACE}] This plugin does not support SSR!`)
return new Proxy(toast, {
get() {
return toast
Expand Down Expand Up @@ -38,7 +38,13 @@ const toastInjectionKey: InjectionKey<ToastInterface> =
const globalEventBus = new EventBus()

const VueToastificationPlugin: Plugin = (App, options?: PluginOptions) => {
const inter = createToastInterface({ eventBus: globalEventBus, ...options })
if (options?.shareAppContext === true) {
options.shareAppContext = App
}
const inter = ownExports.createToastInterface({
eventBus: globalEventBus,
...options,
})
App.provide(toastInjectionKey, inter)
}

Expand Down
17 changes: 16 additions & 1 deletion src/ts/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
PluginOptions,
ToastOptionsAndRequiredContent,
} from "../types"
import { TYPE, EVENTS } from "./constants"
import { TYPE, EVENTS, VT_NAMESPACE } from "./constants"
import { getId, isUndefined } from "./utils"

export const buildInterface = (
Expand All @@ -28,6 +28,21 @@ export const buildInterface = (
if (!isUndefined(onMounted)) {
onMounted(component, app)
}

if (globalOptions.shareAppContext) {
const baseApp = globalOptions.shareAppContext
if (baseApp === true) {
console.warn(
`[${VT_NAMESPACE}] App to share context with was not provided.`
)
} else {
app._context.components = baseApp._context.components
app._context.directives = baseApp._context.directives
app._context.mixins = baseApp._context.mixins
app._context.provides = baseApp._context.provides
app.config.globalProperties = baseApp.config.globalProperties
}
}
})
}
/**
Expand Down
3 changes: 3 additions & 0 deletions src/ts/propValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ const CONTAINER: ComponentObjectPropsOptions<PluginOptionsType> = {
},
containerClassName: COMMON.classNames,
onMounted: Function as PropType<NonNullable<PluginOptions["onMounted"]>>,
shareAppContext: [Boolean, Object] as PropType<
NonNullable<PluginOptions["shareAppContext"]>
>,
}

export default {
Expand Down
9 changes: 9 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@ export interface PluginOptions extends CommonOptions {
containerComponent: ComponentPublicInstance,
containerApp: App<Element>
) => void
/**
* Shares the context of your app with your toasts
*
* This allows toasts to use your app's plugins, mixins, global components, etc.
*
* If you set it to `true`, the app wherein the plugin is installed will be used.
* You may also provide the app instance you wish to use.
*/
shareAppContext?: boolean | App
}

export interface ToastOptions extends CommonOptions {
Expand Down
16 changes: 15 additions & 1 deletion tests/unit/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as index from "../../src/index"
import * as ToastInterfaceModule from "../../src/ts/interface"
import * as utils from "../../src/ts/utils"
import { defineComponent, nextTick } from "vue"
import { VT_NAMESPACE } from "../../src/ts/constants"

const consumerInjected = jest.fn()

Expand Down Expand Up @@ -86,6 +87,19 @@ describe("Toast Plugin", () => {
expect(consumerInjected).toHaveBeenCalledTimes(1)
expect(consumerInjected).toHaveBeenCalledWith(toastInterfaceLike)
})
it("Sends `app` to interface if shareAppContext", () => {
const app = vue.createApp(Parent)
const interfaceSpy = jest.spyOn(index, "createToastInterface")

expect(interfaceSpy).toHaveBeenCalledTimes(0)

app.use(index.default, { shareAppContext: true })

expect(interfaceSpy).toHaveBeenCalledTimes(1)
expect(interfaceSpy).toHaveBeenCalledWith(
expect.objectContaining({ shareAppContext: app })
)
})
})

describe("createToastInterface", () => {
Expand Down Expand Up @@ -136,7 +150,7 @@ describe("createToastInterface", () => {
toast.success("hey")
expect(consoleSpy).toHaveBeenCalledTimes(2)
expect(consoleSpy).toHaveBeenCalledWith(
"[Vue Toastification] This plugin does not support SSR!"
`[${VT_NAMESPACE}] This plugin does not support SSR!`
)
})
})
Expand Down
124 changes: 123 additions & 1 deletion tests/unit/ts/interface.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/* eslint-disable vue/one-component-per-file */
import * as vue from "vue"
import { buildInterface, ToastInterface } from "../../../src/ts/interface"
import { EVENTS, TYPE } from "../../../src/ts/constants"
import { loadPlugin } from "../../utils/plugin"
import { ToastOptions } from "../../../src/types"
import { EventBus } from "../../../src/ts/eventBus"
import { nextTick } from "vue"
import { nextTick, App } from "vue"
import * as index from "../../../src/index"

type Unpacked<T> = T extends (infer U)[]
? U
Expand All @@ -30,6 +32,7 @@ describe("ToastInterface", () => {
wrappers = await loadPlugin({ eventBus })
toast = wrappers.toastInterface
jest.clearAllMocks()
jest.restoreAllMocks()
})

it("calls onMounted", async () => {
Expand Down Expand Up @@ -62,6 +65,125 @@ describe("ToastInterface", () => {
expect(createAppSpy).not.toHaveBeenCalled()
})

it("Shares app context if shareAppContext", async () => {
// Create a base app
const baseApp = vue.createApp({ template: "<div>app</div>" })
// App starts off empty
expect(baseApp._context.components).toEqual({})
expect(baseApp._context.directives).toEqual({})
expect(baseApp._context.mixins.length).toEqual(0)
expect(baseApp._context.provides).toEqual({})
expect(baseApp.config.globalProperties).toEqual({})

// Add a plugin that sets globalProps
baseApp.use(App => {
App.config.globalProperties.newProp = "text"
})
// A custom componrnt
baseApp.component("CustomApp", { template: "<div></div>" })
// A custom directive
baseApp.directive("customDir", {})
// Provide some data
baseApp.provide("provideKey", "value")
// And a custom mixin
baseApp.mixin({
setup() {
return { stuff: 123 }
},
})

// Confirm that app has all values
expect(baseApp._context.components).not.toEqual({})
expect(baseApp._context.directives).not.toEqual({})
expect(baseApp._context.mixins.length).not.toEqual(0)
expect(baseApp._context.provides).not.toEqual({})
expect(baseApp.config.globalProperties).not.toEqual({})

let toastApp: App | undefined = undefined
const pluginOptions: index.PluginOptions = {
onMounted: (_, app) => {
toastApp = app
},
shareAppContext: true,
}
baseApp.use(index.default, pluginOptions)
await nextTick()

toastApp = toastApp as unknown as App

// toast app should share configs with app
expect(toastApp).toBeDefined()
expect(toastApp._context.components).toBe(baseApp._context.components)
expect(toastApp._context.directives).toBe(baseApp._context.directives)
expect(toastApp._context.mixins).toBe(baseApp._context.mixins)
expect(toastApp._context.provides).toBe(baseApp._context.provides)
expect(toastApp.config.globalProperties).toBe(
baseApp.config.globalProperties
)
})

it("Does not share app context if shareAppContext = true", async () => {
// Create a base app
const baseApp = vue.createApp({ template: "<div>app</div>" })
// App starts off empty
expect(baseApp._context.components).toEqual({})
expect(baseApp._context.directives).toEqual({})
expect(baseApp._context.mixins.length).toEqual(0)
expect(baseApp._context.provides).toEqual({})
expect(baseApp.config.globalProperties).toEqual({})

// Add a plugin that sets globalProps
baseApp.use(App => {
App.config.globalProperties.newProp = "text"
})
// A custom componrnt
baseApp.component("CustomApp", { template: "<div></div>" })
// A custom directive
baseApp.directive("customDir", {})
// Provide some data
baseApp.provide("provideKey", "value")
// And a custom mixin
baseApp.mixin({
setup() {
return { stuff: 123 }
},
})

// Confirm that app has all values
expect(baseApp._context.components).not.toEqual({})
expect(baseApp._context.directives).not.toEqual({})
expect(baseApp._context.mixins.length).not.toEqual(0)
expect(baseApp._context.provides).not.toEqual({})
expect(baseApp.config.globalProperties).not.toEqual({})

let toastApp: App | undefined = undefined
const pluginOptions: index.PluginOptions = {
onMounted: (_, app) => {
toastApp = app
},
}
baseApp.use(index.default, pluginOptions)
await nextTick()

toastApp = toastApp as unknown as App

// toast app should share configs with app
expect(toastApp).toBeDefined()
expect(toastApp._context.components).toEqual({})
expect(toastApp._context.directives).toEqual({})
expect(toastApp._context.mixins.length).toEqual(0)
expect(toastApp._context.provides).toEqual({})
expect(toastApp.config.globalProperties).toEqual({})
})

it("warns if shareAppContext = true in buildInterface", async () => {
const consoleSpy = jest.spyOn(console, "warn").mockImplementation()
expect(consoleSpy).toHaveBeenCalledTimes(0)
buildInterface({ shareAppContext: true }, true)
await nextTick()
expect(consoleSpy).toHaveBeenCalledTimes(1)
})

it("calls regular toast function with defaults", () => {
expect(eventsEmmited.add).not.toHaveBeenCalled()
const content = "content"
Expand Down

0 comments on commit 736c2ba

Please sign in to comment.