-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
336 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export { useVIntl } from './useVIntl.js' | ||
export { useMessages, useMessage } from './useMessages.js' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>"', | ||
) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
) | ||
} | ||
}) |