Skip to content

Commit

Permalink
Add useMessage composable
Browse files Browse the repository at this point in the history
  • Loading branch information
brawaru committed Sep 22, 2023
1 parent 3eaf061 commit b194662
Show file tree
Hide file tree
Showing 5 changed files with 336 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .changeset/tender-zoos-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@vintl/vintl': minor
---

Add `useMessages` composable

v5 introduces a new API that allows you to create messages more effectively.
1 change: 1 addition & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useVIntl } from './useVIntl.js'
export { useMessages, useMessage } from './useMessages.js'
161 changes: 161 additions & 0 deletions src/runtime/useMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { MessageDescriptor as MessageDescriptorBase } from '@formatjs/intl'
import type { FormatXMLElementFn, PrimitiveType } from 'intl-messageformat'
import { computed, isRef, reactive, type ComputedRef, type Ref } from 'vue'
import type { IntlController } from '../controller.ts'
import type { MessageValueType } from '../index.ts'
import { useVIntl } from './useVIntl.ts'

type MaybeRef<T> = T | Ref<T>

type PrimitiveValuesRecord = MaybeRef<{
[key: string]: MaybeRef<PrimitiveType | FormatXMLElementFn<PrimitiveType>>
}>

type ValuesRecord<RichTypes> = MaybeRef<{
[key: string]: MaybeRef<
PrimitiveType | RichTypes | FormatXMLElementFn<PrimitiveType | RichTypes>
>
}>

interface MessageDescriptor<RichTypes> extends MessageDescriptorBase {
/**
* A record of the values for arguments used in the message. Can contain Vue
* references, which will be unwrapped, or be a reference itself.
*/
values?: ValuesRecord<RichTypes>
}

type MessageDescriptorOutput<
Descriptor extends MessageDescriptor<RichTypes>,
RichTypes,
> = [Descriptor['values']] extends [undefined]
? string
: Descriptor['values'] extends PrimitiveValuesRecord
? string
: Array<string | RichTypes> | RichTypes | string

type MessageDescriptorsRecord<RichTypes> = Record<
string,
MessageDescriptor<RichTypes>
>

type MessageDescriptorsRecordOutput<
Descriptor extends MessageDescriptorsRecord<RichTypes>,
RichTypes,
> = {
[K in keyof Descriptor]: MessageDescriptorOutput<Descriptor[K], RichTypes>
}

function formatMessage<
Descriptor extends MessageDescriptor<RichTypes>,
RichTypes,
>(
message: Descriptor,
vintl: IntlController<MessageValueType>,
): MessageDescriptorOutput<Descriptor, RichTypes> {
const values = Object.create(null)
const rawInputs = message.values

if (isRef(rawInputs)) {
Object.assign(values, rawInputs.value)
} else if (rawInputs != null) {
for (const k in rawInputs) {
const input = rawInputs[k]
values[k] = isRef(input) ? input.value : input
}
}

return vintl.intl.formatMessage(message, values)
}

/**
* Accepts a plain object of extended message descriptors, which may contain
* `values` alongside the message declaration itself. It then creates an object
* where each descriptor is mapped to a formatted. The object is reactive and
* message properties will be updating when the language, messages or values in
* the messages change.
*
* You can use `toRef` or `useMessages` to create read-only references for
* individual.
*
* @example
* const messages = useMessages({
* farewell: {
* id: 'farewell',
* defaultMessage: 'Goodbye, {user}!',
* values: {
* user: computed(() => user.value.displayName),
* },
* },
* richText: {
* id: 'rich-text',
* defaultMessage: '<red>This text is red.</red>',
* values: {
* red(children) {
* return h('span', { style: { color: 'red' } }, [children])
* },
* },
* },
* })
*
* console.log(messages.farewell) // 'Goodbye, Andrea Rees!'
*
* @param messages A record of message descriptors.
* @returns A reactive map of messages.
*/
export function useMessages<
Descriptor extends MessageDescriptorsRecord<RichTypes>,
RichTypes = MessageValueType,
>(messages: Descriptor) {
const vintl = useVIntl()

type PreOutput = {
[K in keyof MessageDescriptorsRecordOutput<
Descriptor,
RichTypes
>]: ComputedRef<MessageDescriptorsRecordOutput<Descriptor, RichTypes>[K]>
}

const target: PreOutput = Object.create(null)

for (const key of Object.keys(messages) as (keyof Descriptor)[]) {
const message = messages[key]

type Message = Descriptor[typeof key]

target[key] = computed(() =>
formatMessage<Message, RichTypes>(message, vintl),
)
}

return reactive(target)
}

/**
* Accepts an extended message descriptor, which may contain `values` alongside
* the message declaration itself. It then returns a read-only reference that
* gets updated when the language, messages or the values for the message
* change.
*
* @example
* const helloMessage = useMessage({
* id: 'hello',
* defaultMessage: 'Hello, {user}!',
* values: {
* user: computed(() => user.value.displayName),
* },
* })
*
* console.log(helloMessage.value) // 'Hello, Andrea Rees!'
*
* @param message A message descriptor.
* @returns A read-only reference to the actual formatted message.
*/
export function useMessage<
Descriptor extends MessageDescriptor<RichTypes>,
RichTypes = MessageValueType,
>(message: Descriptor) {
const vintl = useVIntl()

return computed(() => formatMessage<Descriptor, RichTypes>(message, vintl))
}
75 changes: 75 additions & 0 deletions test/composables/useMessages/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { cleanup, fireEvent, render } from '@testing-library/vue'
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createVIntlPlugin } from '../../utils/index.ts'
import { messagesPayload, MessageDisplay } from './messageDisplay.tsx'

describe('useMessages', () => {
afterAll(() => cleanup())

const vintl = createVIntlPlugin(['en-US', 'uk'], messagesPayload)

const { plugin, controller, resetController } = vintl

afterEach(resetController)

const { getByText, getByTestId } = render(MessageDisplay, {
global: { plugins: [plugin] },
})

// const display = getByTestId('message-display')
const messageContainer = getByTestId('message-container')
const warningContainer = getByTestId('warning-container')

beforeEach(() => fireEvent.click(getByText('Reset')))

const messageHTML = () => messageContainer.innerHTML
const warningHTML = () => warningContainer.innerHTML

it('renders', async () => {
expect(messageHTML()).toMatchInlineSnapshot(
'"<span>Hello, Andrei!</span><span>You don\'t have unread messages.</span>"',
)
expect(warningHTML()).toMatchInlineSnapshot(
'"<strong>Warning!</strong> This is a warning."',
)

await fireEvent.click(getByText('Add unread'))
expect(messageHTML()).toMatchInlineSnapshot(
'"<span>Hello, Andrei!</span><span>You have 1 unread message.</span>"',
)

await fireEvent.click(getByText('Set intent to goodbye'))
expect(messageHTML()).toMatchInlineSnapshot(
'"<span>Goodbye, Andrei!</span><span>You have 1 unread message.</span>"',
)
})

it('changes locale', async () => {
await controller.changeLocale('uk')
expect(messageHTML()).toMatchInlineSnapshot(
'"<span>Привіт, Andrei!</span><span>У вас немає непрочитаних повідомлень.</span>"',
)
expect(warningHTML()).toMatchInlineSnapshot(
'"<strong>Попередження!</strong> Це попередження."',
)

// 1 = one
await fireEvent.click(getByText('Add unread'))
expect(messageHTML()).toMatchInlineSnapshot(
'"<span>Привіт, Andrei!</span><span>У вас є 1 непрочитане повідомлення.</span>"',
)

// 2 = few
await fireEvent.click(getByText('Add unread'))
expect(messageHTML()).toMatchInlineSnapshot(
'"<span>Привіт, Andrei!</span><span>У вас є 2 непрочитаних повідомлення.</span>"',
)

// safe to assume it will continue to handle number updates

await fireEvent.click(getByText('Set intent to goodbye'))
expect(messageHTML()).toMatchInlineSnapshot(
'"<span>До побачення, Andrei!</span><span>У вас є 2 непрочитаних повідомлення.</span>"',
)
})
})
92 changes: 92 additions & 0 deletions test/composables/useMessages/messageDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { computed, defineComponent, ref } from 'vue'
import { useMessage, useMessages } from '../../../dist/index'

export const messagesPayload: Record<string, Record<string, string>> = {
'en-US': {
greeting: 'Hello, {name}!',
farewell: 'Goodbye, {name}!',
inboxMessages:
"You {count, plural, =0 {don't have unread messages} one {have # unread message} other {have # unread messages}}.",
warnText: '<b>Warning!</b> This is a warning.',
},
uk: {
greeting: 'Привіт, {name}!',
farewell: 'До побачення, {name}!',
inboxMessages:
'У вас {count, plural, =0 {немає непрочитаних повідомлень} one {є # непрочитане повідомлення} few {є # непрочитаних повідомлення} many {є # непрочитаних повідомлень} other {є непрочитаних повідомлень}}.',
warnText: '<b>Попередження!</b> Це попередження.',
},
}

export const MessageDisplay = defineComponent(() => {
const incrementByOne = () => (unreadMessages.value += 1)

const intent = ref<'hello' | 'goodbye'>('hello')
const setIntentToHello = () => (intent.value = 'hello')
const setIntentToGoodbye = () => (intent.value = 'goodbye')

const reset = () => {
name.value = 'Andrei'
unreadMessages.value = 0
intent.value = 'hello'
}

const name = ref('Andrei')

const unreadMessages = ref(0)

const messages = useMessages({
inboxMessages: {
id: 'inboxMessages',
defaultMessage: messagesPayload['en-US'].inboxMessages,
values: { count: unreadMessages },
},
warnText: {
id: 'warnText',
defaultMessage: messagesPayload['en-US'].warnText,
values: {
b(chunks) {
return <strong>{chunks}</strong>
},
},
},
})

const helloMessage = useMessage({
id: 'greeting',
defaultMessage: messagesPayload['en-US'].greeting,
values: { name },
})

const goodbyeMessage = useMessage({
id: 'farewell',
defaultMessage: messagesPayload['en-US'].farewell,
values: { name },
})

const intentMessage = computed(() =>
intent.value === 'hello' ? helloMessage.value : goodbyeMessage.value,
)

const Warning = defineComponent(() => () => messages.warnText)

return () => {
return (
<>
<p data-testid="message-display">
<div data-testid="message-container">
<span>{intentMessage.value}</span>
<span>{messages.inboxMessages}</span>
</div>
<div data-testid="warning-container">
<Warning />
</div>
</p>
<button onClick={incrementByOne}>Add unread</button>
<button onClick={setIntentToHello}>Set intent to hello</button>
<button onClick={setIntentToGoodbye}>Set intent to goodbye</button>
<button onClick={reset}>Reset</button>
</>
)
}
})

0 comments on commit b194662

Please sign in to comment.