sanity-plugin-media 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +56 -4
  3. package/dist/index.d.mts +131 -57
  4. package/dist/index.d.ts +131 -57
  5. package/dist/index.js +273 -106
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +273 -106
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +11 -4
  10. package/src/__tests__/fixtures/createEpicTestStore.ts +27 -0
  11. package/src/__tests__/fixtures/listenMock.ts +9 -0
  12. package/src/__tests__/fixtures/mockSanityClient.ts +84 -0
  13. package/src/__tests__/fixtures/renderWithProviders.tsx +54 -0
  14. package/src/__tests__/fixtures/rootState.ts +27 -0
  15. package/src/__tests__/fixtures/withinDialog.ts +28 -0
  16. package/src/components/Browser/Browser.test.tsx +44 -0
  17. package/src/components/CardAsset/CardAsset.test.tsx +322 -0
  18. package/src/components/DialogAssetEdit/Details.tsx +123 -44
  19. package/src/components/DialogAssetEdit/DialogAssetEdit.test.tsx +215 -0
  20. package/src/components/DialogAssetEdit/index.tsx +138 -30
  21. package/src/components/DialogTagCreate/DialogTagCreate.test.tsx +120 -0
  22. package/src/components/DialogTagEdit/DialogTagEdit.test.tsx +164 -0
  23. package/src/components/FormBuilderTool/FormBuilderTool.test.tsx +62 -0
  24. package/src/components/ReduxProvider/index.tsx +2 -1
  25. package/src/components/UploadDropzone/UploadDropzone.test.tsx +39 -0
  26. package/src/constants.ts +6 -0
  27. package/src/contexts/ToolOptionsContext.tsx +6 -3
  28. package/src/formSchema/index.test.ts +55 -0
  29. package/src/formSchema/index.ts +28 -12
  30. package/src/hooks/useVersionedClient.ts +1 -1
  31. package/src/modules/assets/deleteAndUpdateEpics.test.ts +86 -0
  32. package/src/modules/assets/fetchEpic.test.ts +72 -0
  33. package/src/modules/assets/reducer.test.ts +90 -0
  34. package/src/modules/assets/tagsAndListenerEpics.test.ts +205 -0
  35. package/src/modules/dialog/epics.test.ts +167 -0
  36. package/src/modules/dialog/reducer.test.ts +184 -0
  37. package/src/modules/notifications/epics.test.ts +373 -0
  38. package/src/modules/notifications/index.ts +24 -4
  39. package/src/modules/notifications/reducer.test.ts +53 -0
  40. package/src/modules/search/index.test.ts +35 -0
  41. package/src/modules/selectors.test.ts +20 -0
  42. package/src/modules/tags/epics.test.ts +95 -0
  43. package/src/modules/tags/index.test.ts +41 -0
  44. package/src/modules/uploads/epics.test.ts +108 -0
  45. package/src/modules/uploads/index.test.ts +58 -0
  46. package/src/operators/checkTagName.test.ts +28 -0
  47. package/src/types/index.ts +23 -7
  48. package/src/utils/blocksToText.test.ts +42 -0
  49. package/src/utils/constructFilter.test.ts +119 -0
  50. package/src/utils/generatePreviewBlobUrl.test.ts +69 -0
  51. package/src/utils/getAssetResolution.test.ts +12 -0
  52. package/src/utils/getDocumentAssetIds.test.ts +49 -0
  53. package/src/utils/getSchemeColor.test.ts +11 -0
  54. package/src/utils/getTagSelectOptions.test.ts +43 -0
  55. package/src/utils/getUniqueDocuments.test.ts +25 -0
  56. package/src/utils/imageDprUrl.test.ts +45 -0
  57. package/src/utils/isSupportedAssetType.test.ts +15 -0
  58. package/src/utils/isSupportedAssetType.ts +15 -0
  59. package/src/utils/sanitizeFormData.test.ts +58 -0
  60. package/src/utils/typeGuards.test.ts +17 -0
  61. package/src/utils/uploadSanityAsset.test.ts +28 -0
  62. package/src/utils/withMaxConcurrency.test.ts +42 -0
@@ -0,0 +1,72 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it, vi} from 'vitest'
4
+ import {of, throwError} from 'rxjs'
5
+ import {assetsActions, assetsFetchEpic} from './index'
6
+ import {createEpicTestStore} from '../../__tests__/fixtures/createEpicTestStore'
7
+ import {createMockSanityClient} from '../../__tests__/fixtures/mockSanityClient'
8
+ import type {ImageAsset} from '../../types'
9
+
10
+ function assertFetchSucceeded(store: ReturnType<typeof createEpicTestStore>, asset: ImageAsset) {
11
+ expect(store.getState().assets.byIds.a1?.asset).toEqual(asset)
12
+ expect(store.getState().assets.fetching).toBe(false)
13
+ }
14
+
15
+ function assertFetchFailed(store: ReturnType<typeof createEpicTestStore>) {
16
+ expect(store.getState().assets.fetchingError?.message).toBe('boom')
17
+ }
18
+
19
+ const sampleAsset = {
20
+ _id: 'a1',
21
+ _type: 'sanity.imageAsset',
22
+ _createdAt: '',
23
+ _updatedAt: '',
24
+ _rev: 'r',
25
+ originalFilename: 'x.png',
26
+ size: 1,
27
+ mimeType: 'image/png',
28
+ url: ''
29
+ } as ImageAsset
30
+
31
+ describe('assetsFetchEpic', () => {
32
+ it('dispatches fetchComplete when observable.fetch succeeds', async () => {
33
+ const client = createMockSanityClient({
34
+ observable: {
35
+ fetch: vi.fn(() => of({items: [sampleAsset]}))
36
+ }
37
+ })
38
+
39
+ const store = createEpicTestStore(assetsFetchEpic, client)
40
+ store.dispatch(
41
+ assetsActions.fetchRequest({
42
+ params: {},
43
+ queryFilter: '_type == "sanity.imageAsset"',
44
+ selector: '',
45
+ sort: ''
46
+ })
47
+ )
48
+
49
+ await vi.waitFor(() => assertFetchSucceeded(store, sampleAsset))
50
+ })
51
+
52
+ it('dispatches fetchError when fetch fails', async () => {
53
+ const fetchErr = throwError(() => ({message: 'boom', statusCode: 500}))
54
+ const client = createMockSanityClient({
55
+ observable: {
56
+ fetch: vi.fn(() => fetchErr)
57
+ }
58
+ })
59
+
60
+ const store = createEpicTestStore(assetsFetchEpic, client)
61
+ store.dispatch(
62
+ assetsActions.fetchRequest({
63
+ params: {},
64
+ queryFilter: '_type == "sanity.imageAsset"',
65
+ selector: '',
66
+ sort: ''
67
+ })
68
+ )
69
+
70
+ await vi.waitFor(() => assertFetchFailed(store))
71
+ })
72
+ })
@@ -0,0 +1,90 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it} from 'vitest'
4
+ import type {AssetType, ImageAsset} from '../../types'
5
+ import assetsReducer, {assetsActions, initialState, type AssetsReducerState} from './index'
6
+
7
+ const minimalImage = {
8
+ _id: 'img-1',
9
+ _type: 'sanity.imageAsset',
10
+ _createdAt: '2020-01-01',
11
+ _updatedAt: '2020-01-01',
12
+ _rev: 'r1',
13
+ originalFilename: 'a.png',
14
+ size: 1,
15
+ mimeType: 'image/png',
16
+ url: 'https://example.com/a.png'
17
+ } as ImageAsset
18
+
19
+ describe('assets slice', () => {
20
+ function stateWithOneAsset(): AssetsReducerState {
21
+ return {
22
+ ...initialState,
23
+ assetTypes: ['image'] as AssetType[],
24
+ allIds: ['img-1'],
25
+ byIds: {
26
+ 'img-1': {
27
+ _type: 'asset',
28
+ asset: minimalImage,
29
+ picked: false,
30
+ updating: false
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ it('pick toggles picked flag', () => {
37
+ let state = stateWithOneAsset()
38
+ state = assetsReducer(state, assetsActions.pick({assetId: 'img-1', picked: true}))
39
+ expect(state.byIds['img-1'].picked).toBe(true)
40
+ expect(state.lastPicked).toBe('img-1')
41
+ })
42
+
43
+ it('pickClear clears selection', () => {
44
+ let state = stateWithOneAsset()
45
+ state = assetsReducer(state, assetsActions.pick({assetId: 'img-1', picked: true}))
46
+ state = assetsReducer(state, assetsActions.pickClear())
47
+ expect(state.byIds['img-1'].picked).toBe(false)
48
+ expect(state.lastPicked).toBeUndefined()
49
+ })
50
+
51
+ it('fetchComplete merges assets', () => {
52
+ let state = assetsReducer({...initialState, assetTypes: ['image'] as AssetType[]}, {
53
+ type: '@@INIT'
54
+ } as never)
55
+ state = assetsReducer(state, assetsActions.fetchComplete({assets: [minimalImage]}))
56
+ expect(state.allIds).toContain('img-1')
57
+ expect(state.fetching).toBe(false)
58
+ expect(state.fetchCount).toBe(1)
59
+ })
60
+
61
+ it('orderSet resets page index', () => {
62
+ let state: AssetsReducerState = {
63
+ ...initialState,
64
+ assetTypes: ['image'] as AssetType[],
65
+ pageIndex: 3
66
+ }
67
+ state = assetsReducer(
68
+ state,
69
+ assetsActions.orderSet({
70
+ order: {field: 'size', direction: 'asc'}
71
+ })
72
+ )
73
+ expect(state.pageIndex).toBe(0)
74
+ expect(state.order.field).toBe('size')
75
+ })
76
+
77
+ it('listenerDeleteQueueComplete removes assets', () => {
78
+ let state = stateWithOneAsset()
79
+ state = assetsReducer(state, assetsActions.listenerDeleteQueueComplete({assetIds: ['img-1']}))
80
+ expect(state.allIds).toHaveLength(0)
81
+ expect(state.byIds['img-1']).toBeUndefined()
82
+ })
83
+
84
+ it('listenerUpdateQueueComplete updates asset document', () => {
85
+ let state = stateWithOneAsset()
86
+ const updated = {...minimalImage, title: 'New'}
87
+ state = assetsReducer(state, assetsActions.listenerUpdateQueueComplete({assets: [updated]}))
88
+ expect(state.byIds['img-1'].asset.title).toBe('New')
89
+ })
90
+ })
@@ -0,0 +1,205 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
4
+ import {
5
+ assetsActions,
6
+ assetsListenerCreateQueueEpic,
7
+ assetsListenerDeleteQueueEpic,
8
+ assetsListenerUpdateQueueEpic,
9
+ assetsTagsAddEpic,
10
+ assetsTagsRemoveEpic
11
+ } from './index'
12
+ import {ASSETS_ACTIONS} from './actions'
13
+ import {createEpicTestStore} from '../../__tests__/fixtures/createEpicTestStore'
14
+ import {
15
+ createMockSanityClient,
16
+ mockTransactionCommit
17
+ } from '../../__tests__/fixtures/mockSanityClient'
18
+ import {initialState as assetsInitialState} from './index'
19
+ import type {ImageAsset, Tag} from '../../types'
20
+
21
+ const sampleAsset = {
22
+ _id: 'a1',
23
+ _type: 'sanity.imageAsset',
24
+ _createdAt: '',
25
+ _updatedAt: '',
26
+ _rev: 'r1',
27
+ originalFilename: 'x.png',
28
+ size: 1,
29
+ mimeType: 'image/png',
30
+ url: ''
31
+ } as ImageAsset
32
+
33
+ const sampleTag: Tag = {
34
+ _id: 't1',
35
+ _type: 'media.tag',
36
+ _createdAt: '',
37
+ _updatedAt: '',
38
+ _rev: 'tr',
39
+ name: {_type: 'slug', current: 'tag-a'}
40
+ }
41
+
42
+ describe('assetsTagsAddEpic', () => {
43
+ it('runs transaction.commit when adding tag to picked assets', async () => {
44
+ const tx = mockTransactionCommit(undefined)
45
+ const client = createMockSanityClient({
46
+ transaction: vi.fn(() => tx)
47
+ })
48
+
49
+ const assetItem = {
50
+ _type: 'asset' as const,
51
+ asset: sampleAsset,
52
+ picked: true,
53
+ updating: false
54
+ }
55
+
56
+ const store = createEpicTestStore(assetsTagsAddEpic, client, {
57
+ assets: {
58
+ ...assetsInitialState,
59
+ assetTypes: ['image'],
60
+ allIds: ['a1'],
61
+ byIds: {a1: assetItem}
62
+ },
63
+ tags: {
64
+ allIds: ['t1'],
65
+ byIds: {
66
+ t1: {_type: 'tag', tag: sampleTag, picked: false, updating: false}
67
+ },
68
+ creating: false,
69
+ fetchCount: -1,
70
+ fetching: false,
71
+ panelVisible: true
72
+ }
73
+ })
74
+
75
+ store.dispatch(
76
+ ASSETS_ACTIONS.tagsAddRequest({
77
+ assets: [assetItem],
78
+ tag: sampleTag
79
+ })
80
+ )
81
+
82
+ await vi.waitFor(() => {
83
+ expect(tx.commit).toHaveBeenCalled()
84
+ })
85
+ })
86
+ })
87
+
88
+ describe('assetsTagsRemoveEpic', () => {
89
+ it('runs transaction.commit for tag removal', async () => {
90
+ const tx = mockTransactionCommit(undefined)
91
+ const client = createMockSanityClient({
92
+ transaction: vi.fn(() => tx)
93
+ })
94
+
95
+ const assetItem = {
96
+ _type: 'asset' as const,
97
+ asset: sampleAsset,
98
+ picked: true,
99
+ updating: false
100
+ }
101
+
102
+ const store = createEpicTestStore(assetsTagsRemoveEpic, client, {
103
+ assets: {
104
+ ...assetsInitialState,
105
+ assetTypes: ['image'],
106
+ allIds: ['a1'],
107
+ byIds: {a1: assetItem}
108
+ },
109
+ tags: {
110
+ allIds: ['t1'],
111
+ byIds: {
112
+ t1: {_type: 'tag', tag: sampleTag, picked: false, updating: false}
113
+ },
114
+ creating: false,
115
+ fetchCount: -1,
116
+ fetching: false,
117
+ panelVisible: true
118
+ }
119
+ })
120
+
121
+ store.dispatch(
122
+ ASSETS_ACTIONS.tagsRemoveRequest({
123
+ assets: [assetItem],
124
+ tag: sampleTag
125
+ })
126
+ )
127
+
128
+ await vi.waitFor(() => {
129
+ expect(tx.commit).toHaveBeenCalled()
130
+ })
131
+ })
132
+ })
133
+
134
+ describe('assets listener queue epics', () => {
135
+ beforeEach(() => {
136
+ vi.useFakeTimers()
137
+ })
138
+
139
+ afterEach(() => {
140
+ vi.useRealTimers()
141
+ })
142
+
143
+ it('assetsListenerCreateQueueEpic batches creates after buffer window', async () => {
144
+ const store = createEpicTestStore(assetsListenerCreateQueueEpic, createMockSanityClient(), {
145
+ assets: {
146
+ ...assetsInitialState,
147
+ assetTypes: ['image'],
148
+ allIds: ['a1'],
149
+ byIds: {
150
+ a1: {_type: 'asset', asset: sampleAsset, picked: false, updating: false}
151
+ }
152
+ }
153
+ })
154
+
155
+ const updated = {...sampleAsset, title: 'L'}
156
+ store.dispatch(assetsActions.listenerCreateQueue({asset: updated}))
157
+
158
+ await vi.advanceTimersByTimeAsync(2000)
159
+
160
+ await vi.waitFor(() => {
161
+ expect(store.getState().assets.byIds.a1.asset.title).toBe('L')
162
+ })
163
+ })
164
+
165
+ it('assetsListenerDeleteQueueEpic batches deletes', async () => {
166
+ const store = createEpicTestStore(assetsListenerDeleteQueueEpic, createMockSanityClient(), {
167
+ assets: {
168
+ ...assetsInitialState,
169
+ assetTypes: ['image'],
170
+ allIds: ['a1'],
171
+ byIds: {
172
+ a1: {_type: 'asset', asset: sampleAsset, picked: false, updating: false}
173
+ }
174
+ }
175
+ })
176
+
177
+ store.dispatch(assetsActions.listenerDeleteQueue({assetId: 'a1'}))
178
+ await vi.advanceTimersByTimeAsync(2000)
179
+
180
+ await vi.waitFor(() => {
181
+ expect(store.getState().assets.byIds.a1).toBeUndefined()
182
+ })
183
+ })
184
+
185
+ it('assetsListenerUpdateQueueEpic batches updates', async () => {
186
+ const store = createEpicTestStore(assetsListenerUpdateQueueEpic, createMockSanityClient(), {
187
+ assets: {
188
+ ...assetsInitialState,
189
+ assetTypes: ['image'],
190
+ allIds: ['a1'],
191
+ byIds: {
192
+ a1: {_type: 'asset', asset: sampleAsset, picked: false, updating: false}
193
+ }
194
+ }
195
+ })
196
+
197
+ const updated = {...sampleAsset, title: 'Buffered'}
198
+ store.dispatch(assetsActions.listenerUpdateQueue({asset: updated}))
199
+ await vi.advanceTimersByTimeAsync(2000)
200
+
201
+ await vi.waitFor(() => {
202
+ expect(store.getState().assets.byIds.a1.asset.title).toBe('Buffered')
203
+ })
204
+ })
205
+ })
@@ -0,0 +1,167 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it, vi} from 'vitest'
4
+ import {dialogClearOnAssetUpdateEpic, dialogTagCreateEpic, dialogTagDeleteEpic} from './index'
5
+ import {assetsActions, initialState as assetsInitialState} from '../assets'
6
+ import {tagsActions} from '../tags'
7
+ import {createEpicTestStore} from '../../__tests__/fixtures/createEpicTestStore'
8
+ import {createMockSanityClient} from '../../__tests__/fixtures/mockSanityClient'
9
+ import type {ImageAsset, Tag} from '../../types'
10
+
11
+ const sampleAsset = {
12
+ _id: 'a1',
13
+ _type: 'sanity.imageAsset',
14
+ _createdAt: '',
15
+ _updatedAt: '',
16
+ _rev: 'r1',
17
+ originalFilename: 'x.png',
18
+ size: 1,
19
+ mimeType: 'image/png',
20
+ url: 'https://example.com/x.png'
21
+ } as ImageAsset
22
+
23
+ const sampleTag: Tag = {
24
+ _id: 't1',
25
+ _type: 'media.tag',
26
+ _createdAt: '',
27
+ _updatedAt: '',
28
+ _rev: 'tr',
29
+ name: {_type: 'slug', current: 'alpha'}
30
+ }
31
+
32
+ const tagWithId = (id: string): Tag => ({
33
+ ...sampleTag,
34
+ _id: id
35
+ })
36
+
37
+ describe('dialogClearOnAssetUpdateEpic', () => {
38
+ it('removes the dialog when assets.updateComplete carries closeDialogId', async () => {
39
+ const store = createEpicTestStore(dialogClearOnAssetUpdateEpic, createMockSanityClient({}), {
40
+ assets: {
41
+ ...assetsInitialState,
42
+ assetTypes: ['image'],
43
+ allIds: ['a1'],
44
+ byIds: {
45
+ a1: {_type: 'asset', asset: sampleAsset, picked: false, updating: true}
46
+ }
47
+ },
48
+ dialog: {
49
+ items: [{id: 'a1', type: 'assetEdit', assetId: 'a1'}]
50
+ }
51
+ })
52
+
53
+ store.dispatch(assetsActions.updateComplete({asset: sampleAsset, closeDialogId: 'a1'}))
54
+
55
+ await vi.waitFor(() => {
56
+ expect(store.getState().dialog.items).toHaveLength(0)
57
+ })
58
+ })
59
+
60
+ it('removes the dialog when tags.updateComplete carries closeDialogId', async () => {
61
+ const store = createEpicTestStore(dialogClearOnAssetUpdateEpic, createMockSanityClient({}), {
62
+ tags: {
63
+ allIds: ['t1'],
64
+ byIds: {
65
+ t1: {_type: 'tag', tag: sampleTag, picked: false, updating: true}
66
+ },
67
+ creating: false,
68
+ fetchCount: -1,
69
+ fetching: false,
70
+ panelVisible: true
71
+ },
72
+ dialog: {
73
+ items: [{id: 't1', type: 'tagEdit', tagId: 't1'}]
74
+ }
75
+ })
76
+
77
+ store.dispatch(tagsActions.updateComplete({tag: sampleTag, closeDialogId: 't1'}))
78
+
79
+ await vi.waitFor(() => {
80
+ expect(store.getState().dialog.items).toHaveLength(0)
81
+ })
82
+ })
83
+
84
+ it('does not emit remove when closeDialogId is absent', async () => {
85
+ const store = createEpicTestStore(dialogClearOnAssetUpdateEpic, createMockSanityClient({}), {
86
+ assets: {
87
+ ...assetsInitialState,
88
+ assetTypes: ['image'],
89
+ allIds: ['a1'],
90
+ byIds: {
91
+ a1: {_type: 'asset', asset: sampleAsset, picked: false, updating: true}
92
+ }
93
+ },
94
+ dialog: {
95
+ items: [{id: 'a1', type: 'assetEdit', assetId: 'a1'}]
96
+ }
97
+ })
98
+
99
+ store.dispatch(assetsActions.updateComplete({asset: sampleAsset}))
100
+
101
+ expect(store.getState().dialog.items).toHaveLength(1)
102
+ })
103
+ })
104
+
105
+ describe('dialogTagCreateEpic', () => {
106
+ it('dispatches inlineTagCreate when createComplete includes assetId', async () => {
107
+ const store = createEpicTestStore(dialogTagCreateEpic, createMockSanityClient({}), {
108
+ dialog: {
109
+ items: [{id: 'a1', type: 'assetEdit', assetId: 'a1'}]
110
+ }
111
+ })
112
+
113
+ store.dispatch(tagsActions.createComplete({assetId: 'a1', tag: sampleTag}))
114
+
115
+ await vi.waitFor(() => {
116
+ const item = store.getState().dialog.items[0]
117
+ expect(item?.type).toBe('assetEdit')
118
+ expect('lastCreatedTag' in item && item.lastCreatedTag).toEqual({
119
+ label: 'alpha',
120
+ value: 't1'
121
+ })
122
+ })
123
+ })
124
+
125
+ it('removes tag create dialog when createComplete has no assetId', async () => {
126
+ const store = createEpicTestStore(dialogTagCreateEpic, createMockSanityClient({}), {
127
+ dialog: {
128
+ items: [{id: 'tagCreate', type: 'tagCreate'}]
129
+ }
130
+ })
131
+
132
+ store.dispatch(tagsActions.createComplete({tag: sampleTag}))
133
+
134
+ await vi.waitFor(() => {
135
+ expect(store.getState().dialog.items).toHaveLength(0)
136
+ })
137
+ })
138
+ })
139
+
140
+ describe('dialogTagDeleteEpic', () => {
141
+ it('dispatches inlineTagRemove with listener tag ids', async () => {
142
+ const store = createEpicTestStore(dialogTagDeleteEpic, createMockSanityClient({}), {
143
+ tags: {
144
+ allIds: ['t9'],
145
+ byIds: {
146
+ t9: {_type: 'tag', tag: tagWithId('t9'), picked: false, updating: false}
147
+ },
148
+ creating: false,
149
+ fetchCount: -1,
150
+ fetching: false,
151
+ panelVisible: true
152
+ },
153
+ dialog: {
154
+ items: [{id: 'a1', type: 'assetEdit', assetId: 'a1'}]
155
+ }
156
+ })
157
+
158
+ store.dispatch(tagsActions.listenerDeleteQueueComplete({tagIds: ['t9']}))
159
+
160
+ await vi.waitFor(() => {
161
+ expect(store.getState().dialog.items[0]).toMatchObject({
162
+ type: 'assetEdit',
163
+ lastRemovedTagIds: ['t9']
164
+ })
165
+ })
166
+ })
167
+ })