Skip to content

Commit

Permalink
feat(i18n): translation key autocomplete (#231)
Browse files Browse the repository at this point in the history
* feat(i18n): translation key autocomplete

* fix(i18n): interfaces

* fix(document-viewer): translation reference

* fix: add broken type

* chore: comment

* refactor: change any type to record

* chore: formatting

* chore: test file

* fix(i18n): allow ending on a partial path

* fix(spec-operations): update badge colors [khcp-5779] (#232)

* chore(release): publish [skip ci]

 - @kong-ui-public/spec-renderer@0.7.10

* fix(web-component): bump swagger ui kong theme universal pkg (#233)

* chore(release): publish [skip ci]

 - @kong-ui-public/spec-renderer@0.7.11
 - @kong-ui-public/swagger-ui-web-component@0.4.13

* chore(deps): update dependency @kong/kongponents to ^8.34.1 (#234)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency swagger-ui to ^4.16.0 (#235)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(release): publish [skip ci]

 - @kong-ui-public/metric-cards@0.1.15
 - @kong-ui-public/app-layout@0.13.25
 - @kong-ui-public/copy-uuid@0.2.22
 - @kong-ui-public/misc-widgets@0.2.24
 - @kong-ui-public/document-viewer@0.5.24
 - @kong-ui-public/spec-renderer@0.7.12
 - @kong-ui-public/swagger-ui-web-component@0.4.14

* fix(swagger-ui-web-component): bump package version (#236)

* chore(release): publish [skip ci]

 - @kong-ui-public/spec-renderer@0.7.13
 - @kong-ui-public/swagger-ui-web-component@0.4.15

* chore(deps): update dependency @kong/kongponents to ^8.34.2 (#237)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(release): publish [skip ci]

 - @kong-ui-public/metric-cards@0.1.16
 - @kong-ui-public/app-layout@0.13.26
 - @kong-ui-public/copy-uuid@0.2.23
 - @kong-ui-public/misc-widgets@0.2.25
 - @kong-ui-public/document-viewer@0.5.25
 - @kong-ui-public/spec-renderer@0.7.14

* chore(deps): update dependency @kong/kongponents to ^8.36.0 (#238)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(release): publish [skip ci]

 - @kong-ui-public/metric-cards@0.1.17
 - @kong-ui-public/app-layout@0.13.27
 - @kong-ui-public/copy-uuid@0.2.24
 - @kong-ui-public/misc-widgets@0.2.26
 - @kong-ui-public/document-viewer@0.5.26
 - @kong-ui-public/spec-renderer@0.7.15

* chore(deps): update dependency @kong/kongponents to ^8.37.0 (#239)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update dependency cypress to ^12.7.0 (#240)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(release): publish [skip ci]

 - @kong-ui-public/metric-cards@0.1.18
 - @kong-ui-public/app-layout@0.13.28
 - @kong-ui-public/copy-uuid@0.2.25
 - @kong-ui-public/misc-widgets@0.2.27
 - @kong-ui-public/document-viewer@0.5.27
 - @kong-ui-public/spec-renderer@0.7.16

* fix(types): use proper interface

* chore(i18n): update interface

* test(i18n): update unit tests

* docs(i18n): update examples

* chore: lockfile

* feat(i18n): add interface

---------

Co-authored-by: Aanchal Mishra <87779967+aanchalm01@users.noreply.github.com>
Co-authored-by: Kong UI Bot <konnectx-engineers+kongponents-bot@konghq.com>
Co-authored-by: David Ma <40131297+davidma415@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
  • Loading branch information
5 people authored Mar 14, 2023
1 parent a2d916f commit bdc237d
Show file tree
Hide file tree
Showing 14 changed files with 142 additions and 67 deletions.
72 changes: 60 additions & 12 deletions packages/core/i18n/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# @kong-ui-public/i18n

- [Purpose](#purpose)
- [TypeScript Support](#typescript-support)
- [Global Registration](#global-registration)
- [Vue Plugin](#vue-plugin)
- [Use in application](#use-in-application)
- [Use in shared component](#use-in-shared-component)
- [Formatting messages](#formatting-messages)
Expand All @@ -19,6 +22,50 @@ This is a wrapper around [FormatJS](https://formatjs.io/docs/intl) that allows c
- format numbers
- format dates

## TypeScript Support

In order to provide full autocompletion for the translation keys, you need to pass the type of the `messages` object to the various init functions, as shown here:

> **Note**: For the `i18n-t` component (`Translation.ts`), the type checking and auto-completion for the `keypath` prop are still a work-in-progress due to [the issue described here](https://github.com/Kong/public-ui-components/pull/231).
>
### Global Registration

```ts
// main.ts
import { createI18n, Translation } from '@kong-ui-public/i18n'
import english from './locales/en.json'

const i18n = createI18n<typeof english>('en-us', english, true)
app.use(Translation, { i18n })

// composables/index.ts
import { useI18n } from '@kong-ui-public/i18n'
import english from './locales/en.json'

export default {
// Be sure to provide the interface when exporting
useI18n: useI18n<typeof english>,
}

// Component.vue
import composables from './composables'
const i18n = composables.useI18n()
```

### Vue Plugin

```ts
import { Translation, createI18n } from '@kong-ui-public/i18n'
import english from './locales/en.json'

const i18n = createI18n<typeof english>('en-us', english, true)
const app = createApp(App)
app.use(Translation, { i18n })

app.mount('#app')
```

## Use in application

When used in application we only need to instantiate `Intl` object once, during application hydration, and then we can use it anywhere in the app via `useI18n` composable.
Expand All @@ -32,7 +79,7 @@ import english from './locales/en.json'
...

// this will create Application instance of Intl object
createI18n('en-us', english, true)
createI18n<typeof english>('en-us', english, true)
```

And then, anywhere in application code where `i18n` is needed:
Expand All @@ -44,11 +91,11 @@ And then, anywhere in application code where `i18n` is needed:

<script setup lang="ts">
import { useI18n } from '@kong-ui-public/i18n'
const i18n = useI18n()
import english from './locales/en.json'
const i18n = useI18n<typeof english>()
</script>
```


## Use in shared component

When used in [shared component](https://github.com/Kong/shared-ui-components/pull/118/files#diff-f296a0775f650cac9ecd4b5d7ccef015ca73587be833b40eeae1e396426b0f4fR45) (outside of application code), we need `Intl` that is local to that component, so that it is using component's own localization strings:
Expand All @@ -66,7 +113,7 @@ import english from '../locales/en.json'
// this will instantiate `Intl` local to this component, using component's english messages.
// TODO: load and pass messages based on language passed from consuming application
const i18n = createI18n(props.locale || 'en-us', english)
const i18n = createI18n<typeof english>(props.locale || 'en-us', english)
</script>
```

Expand Down Expand Up @@ -103,7 +150,8 @@ const i18n = createI18n(props.locale || 'en-us', english)

<script setup lang="ts">
import { useI18n } from '@kong-ui-public/i18n'
const i18n = useI18n()
import english from './locales/en.json'
const i18n = useI18n<typeof english>()
</script>
```

Expand Down Expand Up @@ -151,8 +199,9 @@ In `khcp-ui` there are many places where instead of using Intl function we are a

<script setup lang="ts">
import { useI18n } from '@kong-ui-public/i18n'
import english from './locales/en.json'
const i18n = useI18n()
const i18n = useI18n<typeof english>()
const helpText = i18n.source.components.docUploadCard
</script>
```
Expand Down Expand Up @@ -185,7 +234,7 @@ import english from './locales/en.json'
...

//this will create Application instance of Intl object
const i18n = createI18n('en-us', english, true)
const i18n = createI18n<typeof english>('en-us', english, true)
// this will register <i18n-t> component
app.use(Translation, { i18n })
```
Expand Down Expand Up @@ -249,7 +298,6 @@ And then, anywhere in application code where `i18n` is needed

In some cases we do not have access to the Vue `app` and cannot relay on registered i18nT plugin. Working on standalone components in `public-ui-components` is of those cases. And for this your component will look like:


```html
<template>
<i18n-t
Expand All @@ -263,8 +311,8 @@ In some cases we do not have access to the Vue `app` and cannot relay on registe
import { createI18n, i18nTComponent } from '@kong-ui-public/i18n'
import english from './locales/en.json'
const i18n = createI18n('en-us', english)
const i18nT = i18nTComponent(i18n)
const i18n = createI18n<typeof english>('en-us', english)
const i18nT = i18nTComponent<typeof english>(i18n)
</script>
```
Expand All @@ -289,10 +337,10 @@ Or in old `defineComponent` way
import english from './locales/en.json'
export default defineComponent({
components: { i18nT: i18nTComponent() },
components: { i18nT: i18nTComponent<typeof english>() },
setup() {
return {
i18n: createI18n('en-us', english)
i18n: createI18n<typeof english>('en-us', english)
}
}
})
Expand Down
6 changes: 3 additions & 3 deletions packages/core/i18n/sandbox/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ import { useI18n, createI18n, i18nTComponent } from '../src'
import english from './locales/en.json'
// this is grabbing i18n from global
const i18n = useI18n()
const i18n = useI18n<typeof english>()
// this creates local i18n and component
const i18nLocal = createI18n<typeof english>('en-us', english)
const i18nNoPlugin = i18nTComponent(i18nLocal)
const i18nLocal = createI18n('en-us', english)
const i18nNoPlugin = i18nTComponent<typeof english>(i18nLocal)
</script>
2 changes: 1 addition & 1 deletion packages/core/i18n/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import App from './App.vue'

import { Translation, createI18n } from '../src/'
import english from './locales/en.json'
const i18n = createI18n('en-us', english, true)
const i18n = createI18n<typeof english>('en-us', english, true)
const app = createApp(App)
app.use(Translation, { i18n })

Expand Down
8 changes: 7 additions & 1 deletion packages/core/i18n/sandbox/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@
"ok": "O-k-key",
"named": "{greeting}, my name is {name}. And I am {occupation}. I want {amount}",
"default": "See the news at {0}. There is a lot there."
},
"custom_dogs": {
"breeds": {
"husky": "Husky",
"beagle": "Beagle"
}
}
}
}
4 changes: 2 additions & 2 deletions packages/core/i18n/src/Translation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ const english = {
},
}

const i18n = createI18n('en-us', english, true)
const i18n = createI18n<typeof english>('en-us', english, true)

describe('TranslationComponent', () => {
it('should render', () => {
const wrapper = mount({
components: { I18nT: i18nTComponent(i18n) },
components: { I18nT: i18nTComponent<typeof english>(i18n) },

setup: () => {
return {
Expand Down
16 changes: 10 additions & 6 deletions packages/core/i18n/src/Translation.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { defineComponent, h } from 'vue'
import type { VNodeChild, App, PropType } from 'vue'
import type { IntlShapeEx } from './types'
import type {
IntlShapeEx,
// PathToDotNotation, // Enable import to debug `keypath` interface
} from './types'

export const i18nTComponent = (i18n: IntlShapeEx | null = null) => defineComponent({
export const i18nTComponent = <MessageSource extends Record<string, any>>(i18n: IntlShapeEx<MessageSource> | null = null) => defineComponent({
name: 'I18nT',
props: {
i18n: {
type: Object as PropType<IntlShapeEx>,
type: Object as PropType<IntlShapeEx<MessageSource>>,
default: null,
},
keypath: {
type: String,
// type: String as unknown as PropType<PathToDotNotation<MessageSource, string>>, // This breaks the type interface, enable to debug
required: true,
},
tag: {
Expand Down Expand Up @@ -39,7 +43,7 @@ export const i18nTComponent = (i18n: IntlShapeEx | null = null) => defineCompone

return (): VNodeChild => {
const keys = Object.keys(slots).filter(key => key !== '_')
const sourceString = (i18n || props.i18n).messages[props.keypath].toString()
const sourceString = (i18n || props.i18n).messages[(props.keypath as string)].toString()

let hArray: Array<any> = deconstructString(sourceString)

Expand All @@ -66,8 +70,8 @@ export const i18nTComponent = (i18n: IntlShapeEx | null = null) => defineCompone

// Export Vue plugin
export default {
install(app: App, options: { i18n: IntlShapeEx }) {
install<MessageSource extends Record<string, any>>(app: App, options: { i18n: IntlShapeEx<MessageSource> }) {
const { i18n } = options
app.component('I18nT', i18nTComponent(i18n))
app.component('I18nT', i18nTComponent<MessageSource>(i18n))
},
}
45 changes: 24 additions & 21 deletions packages/core/i18n/src/i18n.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,49 +20,50 @@ const english = {
describe('i18n', () => {
describe('createI18n & useI18n', () => {
it('Should create local instance of Intl', () => {
const localI18n = createI18n('en-us', english)
const globalI18n = useI18n()
const localI18n = createI18n<typeof english>('en-us', english)
const globalI18n = useI18n<typeof english>()
expect(globalI18n).toBeUndefined()
expect(localI18n?.t).toBeTypeOf('function')
expect(localI18n?.messages['global.ok']).toEqual('Okay')
expect(localI18n?.source.global).toEqual({ ok: 'Okay' })
})

it('Should create global instance of Intl', () => {
createI18n('en-us', english, true)
const i18n = useI18n()
createI18n<typeof english>('en-us', english, true)
const i18n = useI18n<typeof english>()

expect(i18n?.t).toBeTypeOf('function')
expect(i18n?.messages['global.ok']).toEqual('Okay')
expect(i18n?.source.global).toEqual({ ok: 'Okay' })
})

it('Local and global instances should be different', () => {
createI18n('en-us', { global: { ok: 'not Okay' } }, true)
const globalI18n = useI18n()
const globalInstance = { global: { ok: 'not Okay' } }
createI18n<typeof globalInstance>('en-us', globalInstance, true)
const globalI18n = useI18n<typeof globalInstance>()

const localI18n = createI18n('en-us', english)
const localI18n = createI18n<typeof english>('en-us', english)

expect(globalI18n?.messages['global.ok']).not.toEqual(localI18n?.messages['global.ok'])
})
})

describe('function `t`', () => {
beforeAll(() => {
createI18n('en-us', english, true)
createI18n<typeof english>('en-us', english, true)
})
it('should format simple message', () => {
const { t } = useI18n()
const { t } = useI18n<typeof english>()
expect(t('global.ok')).toEqual('Okay')
})

it('should format message with parameters', () => {
const { t } = useI18n()
const { t } = useI18n<typeof english>()
expect(t('test.withParams', { dayPart: 'Morning', name: 'i18n' })).toEqual('Good Morning, My name is i18n!')
})

it('should format message with pluralization', () => {
const { t } = useI18n()
const { t } = useI18n<typeof english>()
expect(t('test.withPluralization', { count: 0 })).toEqual('There are 0 versions available')
expect(t('test.withPluralization', { count: 1 })).toEqual('There are 1 version available')
expect(t('test.withPluralization', { count: 11 })).toEqual('There are 11 versions available')
Expand All @@ -71,35 +72,37 @@ describe('i18n', () => {

describe('function `te`', () => {
beforeAll(() => {
createI18n('en-us', english, true)
createI18n<typeof english>('en-us', english, true)
})

it('should recognize exiting key', () => {
const { te } = useI18n()
const { te } = useI18n<typeof english>()
expect(te('global.ok')).toBeTruthy()
})

it('should recognize non-exiting key', () => {
const { te } = useI18n()
const { te } = useI18n<typeof english>()
// @ts-expect-error
expect(te('global.not.ok')).toBeFalsy()
})
})

describe('function `tm`', () => {
beforeAll(() => {
createI18n('en-us', english, true)
createI18n<typeof english>('en-us', english, true)
})

it('should return array for exiting key', () => {
const { tm } = useI18n()
const { tm } = useI18n<typeof english>()
expect(tm('array.disabled')).toEqual([
'You cannot update configurations for services',
'You cannot accept new developer portal applications',
])
})

it('should return empty array for non-existing key', () => {
const { tm } = useI18n()
const { tm } = useI18n<typeof english>()
// @ts-expect-error
expect(tm('global.not.ok')).toEqual([])
})
})
Expand All @@ -108,11 +111,11 @@ describe('i18n', () => {

describe('formatNumber', () => {
beforeAll(() => {
createI18n('en-us', english, true)
createI18n<typeof english>('en-us', english, true)
})

it('should format currency', () => {
const { formatNumber } = useI18n()
const { formatNumber } = useI18n<typeof english>()

const formattedNumber = formatNumber(1000, { style: 'currency', currency: 'USD' })
expect(formattedNumber).toEqual('$1,000.00')
Expand All @@ -121,10 +124,10 @@ describe('i18n', () => {

describe('formatDate', () => {
beforeAll(() => {
createI18n('en-us', english, true)
createI18n<typeof english>('en-us', english, true)
})
it('should format date', () => {
const { formatDate } = useI18n()
const { formatDate } = useI18n<typeof english>()

const formattedDate = formatDate(Date.parse('12/12/2012'), {
day: 'numeric',
Expand Down
Loading

0 comments on commit bdc237d

Please sign in to comment.