diff --git a/resources/assets/src/scripts/route.tsx b/resources/assets/src/scripts/route.tsx
index de640ced..29840ac8 100644
--- a/resources/assets/src/scripts/route.tsx
+++ b/resources/assets/src/scripts/route.tsx
@@ -21,8 +21,8 @@ export default [
},
{
path: 'user/closet',
- component: () => import('../views/user/Closet.vue'),
- el: virtual,
+ react: () => import('../views/user/Closet'),
+ el: '#closet-list',
},
{
path: 'user/player',
diff --git a/resources/assets/src/scripts/types.ts b/resources/assets/src/scripts/types.ts
index 1544815e..4f02a9c2 100644
--- a/resources/assets/src/scripts/types.ts
+++ b/resources/assets/src/scripts/types.ts
@@ -7,8 +7,18 @@ export type Player = {
export type Texture = {
tid: number
- hash: string
+ name: string
type: TextureType
+ hash: string
+ size: number
+ uploader: number
+ public: boolean
+ upload_at: string
+ likes: number
}
export type TextureType = 'steve' | 'alex' | 'cape'
+
+export type ClosetItem = Texture & {
+ pivot: { user_uid: number; texture_tid: number; item_name: string }
+}
diff --git a/resources/assets/src/scripts/utils.ts b/resources/assets/src/scripts/utils.ts
index d5173a17..f801f1a1 100644
--- a/resources/assets/src/scripts/utils.ts
+++ b/resources/assets/src/scripts/utils.ts
@@ -1,11 +1,3 @@
-export function debounce(func: CallableFunction, delay: number) {
- let timer: number
- return () => {
- clearTimeout(timer)
- timer = setTimeout(func, delay)
- }
-}
-
export function queryString(key: string, defaultValue: string = ''): string {
const result = new RegExp(`[?&]${key}=([^&]+)`, 'i').exec(location.search)
@@ -16,8 +8,7 @@ export function queryString(key: string, defaultValue: string = ''): string {
}
export function queryStringify(params: { [key: string]: string }): string {
- return Object
- .keys(params)
+ return Object.keys(params)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&')
}
diff --git a/resources/assets/src/styles/_utils.scss b/resources/assets/src/styles/_utils.scss
new file mode 100644
index 00000000..97ca7bb3
--- /dev/null
+++ b/resources/assets/src/styles/_utils.scss
@@ -0,0 +1,5 @@
+@mixin truncate-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/resources/assets/src/views/user/Closet.vue b/resources/assets/src/views/user/Closet.vue
deleted file mode 100644
index 0952cc73..00000000
--- a/resources/assets/src/views/user/Closet.vue
+++ /dev/null
@@ -1,294 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/assets/src/views/user/Closet/ClosetItem.module.scss b/resources/assets/src/views/user/Closet/ClosetItem.module.scss
new file mode 100644
index 00000000..82048e93
--- /dev/null
+++ b/resources/assets/src/views/user/Closet/ClosetItem.module.scss
@@ -0,0 +1,23 @@
+@use '../../../styles/utils';
+
+.item {
+ width: 235px;
+ transition-property: box-shadow;
+ transition-duration: 0.3s;
+
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.icon:hover {
+ color: #000;
+}
+
+.bg {
+ background-color: #eff1f0;
+}
+
+.truncate {
+ @include utils.truncate-text;
+}
diff --git a/resources/assets/src/views/user/Closet/ClosetItem.tsx b/resources/assets/src/views/user/Closet/ClosetItem.tsx
new file mode 100644
index 00000000..74d776fe
--- /dev/null
+++ b/resources/assets/src/views/user/Closet/ClosetItem.tsx
@@ -0,0 +1,98 @@
+import React from 'react'
+import { t } from '@/scripts/i18n'
+import * as fetch from '@/scripts/net'
+import { showModal, toast } from '@/scripts/notify'
+import { ClosetItem } from '@/scripts/types'
+import styles from './ClosetItem.module.scss'
+
+interface Props {
+ item: ClosetItem
+ selected: boolean
+ onClick(item: ClosetItem): void
+ onRename(): void
+ onRemove(): void
+}
+
+const ClosetItem: React.FC
= props => {
+ const { item } = props
+
+ const handleItemClick = () => {
+ props.onClick(item)
+ }
+
+ const setAsAvatar = async () => {
+ try {
+ await showModal({
+ title: t('user.setAvatar'),
+ text: t('user.setAvatarNotice'),
+ })
+ } catch {
+ return
+ }
+
+ const { code, message } = await fetch.post(
+ '/user/profile/avatar',
+ { tid: item.tid },
+ )
+ if (code === 0) {
+ toast.success(message)
+ document
+ .querySelectorAll('[alt="User Image"]')
+ .forEach(el => (el.src += `?${new Date().getTime()}`))
+ } else {
+ toast.error(message)
+ }
+ }
+
+ return (
+
+
+

+
+
+
+
+ {item.pivot.item_name}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ClosetItem
diff --git a/resources/assets/src/views/user/Closet/ModalApply.tsx b/resources/assets/src/views/user/Closet/ModalApply.tsx
new file mode 100644
index 00000000..a199015c
--- /dev/null
+++ b/resources/assets/src/views/user/Closet/ModalApply.tsx
@@ -0,0 +1,108 @@
+import React, { useState, useEffect } from 'react'
+import $ from 'jquery'
+import { t } from '@/scripts/i18n'
+import * as fetch from '@/scripts/net'
+import { toast } from '@/scripts/notify'
+import { Player } from '@/scripts/types'
+import Loading from '@/components/Loading'
+import Modal from '@/components/Modal'
+
+const baseUrl = blessing.base_url
+
+interface Props {
+ show: boolean
+ canAdd: boolean
+ skin?: number
+ cape?: number
+ onClose(): void
+}
+
+const ModalApply: React.FC = props => {
+ const [players, setPlayers] = useState([])
+ const [search, setSearch] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+
+ useEffect(() => {
+ if (!props.show) {
+ return
+ }
+
+ const getPlayers = async () => {
+ setIsLoading(true)
+ const { data } = await fetch.get>(
+ '/user/player/list',
+ )
+ setPlayers(data)
+ setIsLoading(false)
+ }
+ getPlayers()
+ }, [props.show])
+
+ const handleSearch = (event: React.ChangeEvent) => {
+ setSearch(event.target.value)
+ }
+
+ const handleSelect = async (player: Player) => {
+ const { code, message } = await fetch.post(
+ `/user/player/set/${player.pid}`,
+ {
+ skin: props.skin,
+ cape: props.cape,
+ },
+ )
+ if (code === 0) {
+ toast.success(message)
+ $('#modal-apply').modal('hide')
+ } else {
+ toast.error(message)
+ }
+ }
+
+ return (
+ >}
+ onClose={props.onClose}
+ >
+ {isLoading ? (
+
+ ) : players.length === 0 ? (
+ {t('user.closet.use-as.empty')}
+ ) : (
+ <>
+
+
+
+ {players
+ .filter(player => player.name.includes(search))
+ .map(player => (
+
+ ))}
+ >
+ )}
+
+ )
+}
+
+export default ModalApply
diff --git a/resources/assets/src/views/user/Closet/Previewer.tsx b/resources/assets/src/views/user/Closet/Previewer.tsx
new file mode 100644
index 00000000..ab956bab
--- /dev/null
+++ b/resources/assets/src/views/user/Closet/Previewer.tsx
@@ -0,0 +1,38 @@
+import React, { useEffect } from 'react'
+import ReactDOM from 'react-dom'
+import ViewerSkeleton from '@/components/ViewerSkeleton'
+
+const Viewer = React.lazy(() => import('@/components/Viewer'))
+
+interface Props {
+ skin?: string
+ cape?: string
+ isAlex: boolean
+}
+
+const container = document.createElement('div')
+
+const Previewer: React.FC = props => {
+ useEffect(() => {
+ const mount = document.querySelector('#previewer')!
+ mount.appendChild(container)
+
+ return () => {
+ mount.removeChild(container)
+ }
+ }, [])
+
+ const skin = props.skin ? `${blessing.base_url}/textures/${props.skin}` : ''
+ const cape = props.cape ? `${blessing.base_url}/textures/${props.cape}` : ''
+
+ return ReactDOM.createPortal(
+ }>
+
+ {props.children}
+
+ ,
+ container,
+ )
+}
+
+export default Previewer
diff --git a/resources/assets/src/views/user/Closet/index.tsx b/resources/assets/src/views/user/Closet/index.tsx
new file mode 100644
index 00000000..a5eef8f2
--- /dev/null
+++ b/resources/assets/src/views/user/Closet/index.tsx
@@ -0,0 +1,275 @@
+import React, { useState, useEffect } from 'react'
+import { hot } from 'react-hot-loader/root'
+import debounce from 'lodash.debounce'
+import { t } from '@/scripts/i18n'
+import * as fetch from '@/scripts/net'
+import { showModal, toast } from '@/scripts/notify'
+import { ClosetItem as Item, Texture } from '@/scripts/types'
+import Loading from '@/components/Loading'
+import Pagination from '@/components/Pagination'
+import ClosetItem from './ClosetItem'
+import Previewer from './Previewer'
+import ModalApply from './ModalApply'
+
+type Category = 'skin' | 'cape'
+
+const updater = debounce(
+ (
+ value: React.SetStateAction,
+ setter: React.Dispatch>,
+ ) => setter(value),
+ 350,
+)
+
+const Closet: React.FC = () => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [category, setCategory] = useState('skin')
+ const [search, setSearch] = useState('')
+ const [query, setQuery] = useState('')
+ const [page, setPage] = useState(1)
+ const [totalPages, setTotalPages] = useState(1)
+ const [items, setItems] = useState- ([])
+ const [skin, setSkin] = useState(null)
+ const [cape, setCape] = useState(null)
+ const [showModalApply, setShowModalApply] = useState(false)
+
+ useEffect(() => {
+ const getItems = async () => {
+ setIsLoading(true)
+ const {
+ data: { items, category: c, total_pages: totalPages },
+ } = await fetch.get<
+ fetch.ResponseBody<{
+ items: Item[]
+ category: Category
+ total_pages: number
+ }>
+ >('/user/closet/list', {
+ category,
+ q: query,
+ page,
+ })
+
+ setItems(items)
+ setCategory(c)
+ setTotalPages(totalPages)
+ setIsLoading(false)
+ }
+ getItems()
+ }, [category, query, page])
+
+ const switchCategory = () => {
+ setCategory(category => (category === 'skin' ? 'cape' : 'skin'))
+ }
+
+ const handleSearch = (event: React.ChangeEvent) => {
+ const value = event.target.value
+ setSearch(value)
+ updater(value, setQuery)
+ }
+
+ const handlePageChange = (page: number) => setPage(page)
+
+ const isSelected = (item: Item): boolean => {
+ if (category === 'skin') {
+ return item.tid === skin?.tid
+ } else {
+ return item.tid === cape?.tid
+ }
+ }
+
+ const handleSelect = (item: Item) => {
+ if (item.type === 'cape') {
+ setCape(item)
+ } else {
+ setSkin(item)
+ }
+ }
+
+ const resetSelected = () => {
+ setSkin(null)
+ setCape(null)
+ }
+
+ const renameItem = async (item: Item, index: number) => {
+ let name: string
+ try {
+ const { value } = await showModal({
+ mode: 'prompt',
+ text: t('user.renameClosetItem'),
+ input: item.pivot.item_name,
+ validator: (value: string) => {
+ if (!value) {
+ return t('skinlib.emptyNewTextureName')
+ }
+ },
+ })
+ name = value
+ } catch {
+ return
+ }
+
+ const { code, message } = await fetch.post(
+ `/user/closet/rename/${item.tid}`,
+ { name },
+ )
+ if (code === 0) {
+ toast.success(message)
+ setItems(items => {
+ items[index] = Object.assign({}, item, {
+ pivot: Object.assign({}, item.pivot, { item_name: name }),
+ })
+ return items.slice()
+ })
+ } else {
+ toast.error(message)
+ }
+ }
+
+ const removeItem = async (item: Item) => {
+ try {
+ await showModal({
+ text: t('user.removeFromClosetNotice'),
+ okButtonType: 'danger',
+ })
+ } catch {
+ return
+ }
+
+ const { code, message } = await fetch.post(
+ `/user/closet/remove/${item.tid}`,
+ )
+ if (code === 0) {
+ toast.success(message)
+ const { tid } = item
+ setItems(items => items.filter(item => item.tid !== tid))
+ } else {
+ toast.error(message)
+ }
+ }
+
+ const applyToPlayer = () => {
+ if (!skin && !cape) {
+ toast.info(t('user.emptySelectedTexture'))
+ return
+ }
+ setShowModalApply(true)
+ }
+
+ return (
+ <>
+
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+ {search ? (
+ t('general.noResult')
+ ) : (
+
+ )}
+
+ ) : (
+
+ {items.map((item, i) => (
+ renameItem(item, i)}
+ onRemove={() => removeItem(item)}
+ />
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+ setShowModalApply(false)}
+ />
+ >
+ )
+}
+
+export default hot(Closet)
diff --git a/resources/assets/tests/__mocks__/lodash.debounce.ts b/resources/assets/tests/__mocks__/lodash.debounce.ts
new file mode 100644
index 00000000..9cb03350
--- /dev/null
+++ b/resources/assets/tests/__mocks__/lodash.debounce.ts
@@ -0,0 +1,3 @@
+export default function debounce(fn: Function, _?: number) {
+ return fn
+}
diff --git a/resources/assets/tests/components/AddPlayerDialog.test.ts b/resources/assets/tests/components/AddPlayerDialog.test.ts
deleted file mode 100644
index 63c70acf..00000000
--- a/resources/assets/tests/components/AddPlayerDialog.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import Vue from 'vue'
-import { mount } from '@vue/test-utils'
-import { flushPromises } from '../utils'
-import { toast } from '@/scripts/notify'
-import Modal from '@/components/Modal.vue'
-import AddPlayerDialog from '@/components/AddPlayerDialog.vue'
-
-jest.mock('@/scripts/notify')
-
-window.blessing.extra = {
- rule: 'rule',
- length: 'length',
-}
-
-test('add player', async () => {
- Vue.prototype.$http.get.mockResolvedValueOnce([])
- Vue.prototype.$http.post
- .mockResolvedValueOnce({ code: 1, message: 'fail' })
- .mockResolvedValue({ code: 0, message: 'ok' })
- const wrapper = mount(AddPlayerDialog)
- const modal = wrapper.find(Modal)
- wrapper.find('input[type="text"]').setValue('the-new')
-
- modal.vm.$emit('confirm')
- expect(Vue.prototype.$http.post).toBeCalledWith(
- '/user/player/add',
- { name: 'the-new' },
- )
- await flushPromises()
- expect(wrapper.text()).not.toContain('the-new')
- expect(toast.error).toBeCalledWith('fail')
-
- modal.vm.$emit('confirm')
- await flushPromises()
- expect(wrapper.emitted().add).toBeDefined()
- expect(toast.success).toBeCalledWith('ok')
-})
diff --git a/resources/assets/tests/components/ClosetItem.test.ts b/resources/assets/tests/components/ClosetItem.test.ts
deleted file mode 100644
index ccd50dfd..00000000
--- a/resources/assets/tests/components/ClosetItem.test.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import Vue from 'vue'
-import { mount } from '@vue/test-utils'
-import { flushPromises } from '../utils'
-import { showModal } from '@/scripts/notify'
-import ClosetItem from '@/components/ClosetItem.vue'
-
-jest.mock('@/scripts/notify')
-
-function factory(opt = {}) {
- return {
- tid: 1,
- name: 'texture',
- type: 'steve',
- ...opt,
- }
-}
-
-test('computed values', () => {
- const wrapper = mount(ClosetItem, { propsData: factory() })
- expect(wrapper.find('img').attributes('src')).toBe('/preview/1?height=150')
- expect(
- wrapper
- .findAll('.dropdown-item')
- .at(2)
- .attributes('href'),
- ).toBe('/skinlib/show/1')
-})
-
-test('selected item', () => {
- const wrapper = mount(ClosetItem, { propsData: factory({ selected: true }) })
- expect(wrapper.find('.card').classes('shadow')).toBeTrue()
-})
-
-test('click item body', () => {
- const wrapper = mount(ClosetItem, { propsData: factory() })
-
- wrapper.find('.card').trigger('click')
- expect(wrapper.emitted().select).toBeUndefined()
-
- wrapper.find('.card-body').trigger('click')
- expect(wrapper.emitted().select).toBeTruthy()
-})
-
-test('rename texture', async () => {
- Vue.prototype.$http.post
- .mockResolvedValueOnce({ code: 0 })
- .mockResolvedValueOnce({ code: 1 })
- showModal
- .mockRejectedValueOnce(null)
- .mockResolvedValue({ value: 'new-name' })
- const wrapper = mount(ClosetItem, { propsData: factory() })
- const button = wrapper.findAll('.dropdown-item').at(0)
-
- button.trigger('click')
- await flushPromises()
- expect(Vue.prototype.$http.post).not.toBeCalled()
-
- // Warning message
- button.trigger('click')
- await flushPromises()
-
- button.trigger('click')
- await flushPromises()
- expect(wrapper.find('[data-test="name"]').text()).toBe('new-name (steve)')
- expect(Vue.prototype.$http.post).toBeCalledWith(
- '/user/closet/rename/1',
- { name: 'new-name' },
- )
-})
-
-test('remove texture', async () => {
- Vue.prototype.$http.post
- .mockResolvedValueOnce({ code: 0 })
- .mockResolvedValueOnce({ code: 1 })
- showModal
- .mockRejectedValueOnce(null)
- .mockResolvedValue({ value: '' })
-
- const wrapper = mount(ClosetItem, { propsData: factory() })
- const button = wrapper.findAll('.dropdown-item').at(1)
-
- button.trigger('click')
- await flushPromises()
- expect(Vue.prototype.$http.post).not.toBeCalled()
-
- button.trigger('click')
- await flushPromises()
-
- button.trigger('click')
- await flushPromises()
- expect(wrapper.emitted()['item-removed']).toBeTruthy()
- expect(Vue.prototype.$http.post).toBeCalledWith('/user/closet/remove/1')
-})
-
-test('set as avatar', async () => {
- Vue.prototype.$http.post
- .mockResolvedValueOnce({ code: 0 })
- .mockResolvedValueOnce({ code: 1 })
- showModal
- .mockRejectedValueOnce(null)
- .mockResolvedValue({ value: '' })
-
- const wrapper = mount(ClosetItem, { propsData: factory() })
- const button = wrapper.findAll('.dropdown-item').at(3)
- document.body.innerHTML += '
'
-
- button.trigger('click')
- await flushPromises()
- expect(Vue.prototype.$http.post).not.toBeCalled()
-
- button.trigger('click')
- await flushPromises()
-
- button.trigger('click')
- await flushPromises()
- await flushPromises()
- expect(Vue.prototype.$http.post).toBeCalledWith('/user/profile/avatar', { tid: 1 })
- expect(document.querySelector('img')!.src).toMatch(/\d+$/)
-})
-
-test('no avatar option if texture is cape', () => {
- const wrapper = mount(ClosetItem, { propsData: factory({ type: 'cape' }) })
- expect(wrapper.findAll('.dropdown-item')).toHaveLength(3)
-})
-
-test('truncate too long texture name', () => {
- const wrapper = mount(ClosetItem, {
- propsData: factory({ name: 'very-very-long-texture-name' }),
- })
- expect(wrapper.text()).toContain('very-very-long-...')
-})
diff --git a/resources/assets/tests/scripts/utils.test.ts b/resources/assets/tests/scripts/utils.test.ts
index 7559f843..4586faf4 100644
--- a/resources/assets/tests/scripts/utils.test.ts
+++ b/resources/assets/tests/scripts/utils.test.ts
@@ -1,16 +1,5 @@
import * as utils from '@/scripts/utils'
-test('debounce', () => {
- const stub = jest.fn()
- const debounced = utils.debounce(stub, 2000)
-
- debounced()
- debounced()
- expect(stub).not.toBeCalled()
- jest.runAllTimers()
- expect(stub).toBeCalledTimes(1)
-})
-
test('queryString', () => {
history.pushState({}, 'page', `${location.href}?key=value`)
expect(utils.queryString('key')).toBe('value')
diff --git a/resources/assets/tests/views/user/Closet.test.ts b/resources/assets/tests/views/user/Closet.test.ts
deleted file mode 100644
index 6fb07619..00000000
--- a/resources/assets/tests/views/user/Closet.test.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import Vue from 'vue'
-import { mount } from '@vue/test-utils'
-import { flushPromises } from '../../utils'
-import { trans } from '@/scripts/i18n'
-import Closet from '@/views/user/Closet.vue'
-import ClosetItem from '@/components/ClosetItem.vue'
-import Previewer from '@/components/Previewer.vue'
-
-beforeEach(() => {
- window.blessing.extra = { unverified: false }
-})
-
-test('fetch closet data before mount', () => {
- Vue.prototype.$http.get.mockResolvedValue({ data: {} })
- mount(Closet)
- jest.runAllTicks()
- expect(Vue.prototype.$http.get).toBeCalledWith(
- '/user/closet/list',
- {
- category: 'skin',
- q: '',
- page: 1,
- },
- )
-})
-
-test('switch tabs', () => {
- Vue.prototype.$http.get.mockResolvedValue({
- data: {
- items: [],
- category: 'skin',
- total_pages: 1,
- },
- }).mockResolvedValueOnce({
- data: {
- items: [],
- category: 'cape',
- total_pages: 1,
- },
- })
-
- const wrapper = mount(Closet)
-
- wrapper
- .findAll('.nav-link')
- .at(0)
- .trigger('click')
- jest.runAllTicks()
- expect(Vue.prototype.$http.get).toBeCalledWith(
- '/user/closet/list',
- {
- category: 'skin',
- q: '',
- page: 1,
- },
- )
- wrapper
- .findAll('.nav-link')
- .at(1)
- .trigger('click')
- jest.runAllTicks()
- expect(Vue.prototype.$http.get).toBeCalledWith(
- '/user/closet/list',
- {
- category: 'cape',
- q: '',
- page: 1,
- },
- )
-})
-
-test('different categories', () => {
- Vue.prototype.$http.get.mockResolvedValue({ data: {} })
-
- const wrapper = mount(Closet)
- expect(
- wrapper
- .findAll('.nav-link')
- .at(0)
- .classes('active'),
- ).toBeTrue()
- expect(wrapper.find('#skin-category').classes('active')).toBeTrue()
-
- wrapper.setData({ category: 'cape' })
- expect(
- wrapper
- .findAll('.nav-link')
- .at(1)
- .classes('active'),
- ).toBeTrue()
- expect(wrapper.find('#cape-category').classes('active')).toBeTrue()
-})
-
-test('search textures', () => {
- Vue.prototype.$http.get.mockResolvedValue({ data: {} })
-
- const wrapper = mount(Closet)
- const input = wrapper.find('input')
- ;(input.element as HTMLInputElement).value = 'q'
- input.trigger('input')
- jest.runAllTimers()
- jest.runAllTicks()
- expect(Vue.prototype.$http.get).toBeCalledWith(
- '/user/closet/list',
- {
- category: 'skin',
- q: 'q',
- page: 1,
- },
- )
-})
-
-test('empty closet', () => {
- Vue.prototype.$http.get.mockResolvedValue({ data: {} })
- const wrapper = mount(Closet)
- // expect(wrapper.find('#skin-category').text()).toContain(trans('user.emptyClosetMsg'))
- wrapper.setData({ category: 'cape' })
- // expect(wrapper.find('#cape-category').text()).toContain(trans('user.emptyClosetMsg'))
-})
-
-test('no matched search result', () => {
- Vue.prototype.$http.get.mockResolvedValue({ data: {} })
- const wrapper = mount(Closet)
- wrapper.setData({ query: 'q' })
- expect(wrapper.find('#skin-category').text()).toContain(trans('general.noResult'))
- wrapper.setData({ category: 'cape' })
- expect(wrapper.find('#cape-category').text()).toContain(trans('general.noResult'))
-})
-
-test('render items', async () => {
- Vue.prototype.$http.get.mockResolvedValue({
- data: {
- items: [
- { tid: 1 },
- { tid: 2 },
- ],
- category: 'skin',
- total_pages: 1,
- },
- })
- const wrapper = mount(Closet)
- await flushPromises()
- expect(wrapper.findAll(ClosetItem)).toHaveLength(2)
-})
-
-test('reload closet when page changed', () => {
- Vue.prototype.$http.get.mockResolvedValue({ data: {} })
- const wrapper = mount(Closet)
- wrapper.vm.pageChanged()
- jest.runAllTicks()
- expect(Vue.prototype.$http.get).toBeCalledTimes(2)
-})
-
-test('remove skin item', () => {
- Vue.prototype.$http.get.mockResolvedValue({ data: {} })
- const wrapper = mount(Closet)
- wrapper.setData({ skinItems: [{ tid: 1 }] })
- wrapper.vm.removeSkinItem(0)
- // expect(wrapper.find('#skin-category').text()).toContain(trans('user.emptyClosetMsg'))
-})
-
-test('remove cape item', () => {
- Vue.prototype.$http.get.mockResolvedValue({ data: {} })
- const wrapper = mount(Closet)
- wrapper.setData({ capeItems: [{ tid: 1 }], category: 'cape' })
- wrapper.vm.removeCapeItem(0)
- // expect(wrapper.find('#cape-category').text()).toContain(trans('user.emptyClosetMsg'))
-})
-
-test('select texture', async () => {
- Vue.prototype.$http.get
- .mockResolvedValueOnce({ data: {} })
- .mockResolvedValueOnce({ data: { type: 'steve', hash: 'a' } })
- .mockResolvedValueOnce({ data: { type: 'cape', hash: 'b' } })
-
- const wrapper = mount(Closet)
- wrapper.setData({ skinItems: [{ tid: 1 }] })
- wrapper.find(ClosetItem).vm.$emit('select')
- await flushPromises()
- expect(Vue.prototype.$http.get).toBeCalledWith('/skinlib/info/1')
- expect(wrapper.vm.skinUrl).toBe('/textures/a')
-
- wrapper.setData({
- skinItems: [], capeItems: [{ tid: 2 }], category: 'cape',
- })
- wrapper.find(ClosetItem).vm.$emit('select')
- await flushPromises()
- expect(Vue.prototype.$http.get).toBeCalledWith('/skinlib/info/2')
- expect(wrapper.vm.capeUrl).toBe('/textures/b')
-})
-
-test('apply texture', async () => {
- Vue.prototype.$http.get
- .mockResolvedValueOnce({ data: {} })
- .mockResolvedValueOnce({ data: [] })
- .mockResolvedValueOnce({
- data: [
- {
- pid: 1, name: 'name', tid_skin: 10,
- },
- ],
- })
-
- const wrapper = mount(Closet)
- const button = wrapper.find(Previewer).findAll('button')
- .at(0)
- button.trigger('click')
- jest.runAllTicks()
- expect(wrapper.find('.modal-body').text()).toContain(trans('user.closet.use-as.empty'))
-
- button.trigger('click')
- await flushPromises()
- expect(wrapper.find('.btn-outline-info').text()).toContain('name')
- expect(wrapper.find('.btn-outline-info > img').attributes('src'))
- .toBe('/avatar/10?3d&size=45')
-})
-
-test('reset selected texture', () => {
- Vue.prototype.$http.get.mockResolvedValue({ data: {} })
- const wrapper = mount(Closet)
- wrapper.setData({
- selectedSkin: 1,
- selectedCape: 2,
- skinUrl: 'a',
- capeUrl: 'b',
- })
- wrapper.find('[data-test="resetSelected"]').trigger('click')
- expect(wrapper.vm).toEqual(expect.objectContaining({
- selectedSkin: 0,
- selectedCape: 0,
- skinUrl: '',
- capeUrl: '',
- }))
-})
-
-test('select specified texture initially', async () => {
- window.history.pushState({}, 'title', `${location.href}?tid=1`)
- window.$ = jest.fn(() => ({
- modal() {},
- }))
- Vue.prototype.$http.get
- .mockResolvedValueOnce({
- data: {
- items: [],
- category: 'skin',
- total_pages: 1,
- },
- })
- .mockResolvedValueOnce({ data: { type: 'cape', hash: '' } })
- .mockResolvedValueOnce([])
- mount(Closet)
- jest.runAllTimers()
- await flushPromises()
- jest.unmock('@/scripts/utils')
-})
diff --git a/resources/assets/tests/views/user/Closet.test.tsx b/resources/assets/tests/views/user/Closet.test.tsx
new file mode 100644
index 00000000..842c1f12
--- /dev/null
+++ b/resources/assets/tests/views/user/Closet.test.tsx
@@ -0,0 +1,521 @@
+import React from 'react'
+import { render, fireEvent, wait } from '@testing-library/react'
+import $ from 'jquery'
+import { t } from '@/scripts/i18n'
+import * as fetch from '@/scripts/net'
+import { ClosetItem, Player } from '@/scripts/types'
+import Closet from '@/views/user/Closet'
+
+jest.mock('@/scripts/net')
+
+const fixtureSkin: Readonly = Object.freeze({
+ tid: 1,
+ name: 'skin',
+ type: 'steve',
+ hash: 'abc',
+ size: 2,
+ uploader: 1,
+ public: true,
+ upload_at: new Date().toString(),
+ likes: 1,
+ pivot: {
+ user_uid: 1,
+ texture_tid: 1,
+ item_name: 'closet_skin',
+ },
+})
+
+const fixtureCape: Readonly = Object.freeze({
+ tid: 2,
+ name: 'cape',
+ type: 'cape',
+ hash: 'def',
+ size: 2,
+ uploader: 1,
+ public: true,
+ upload_at: new Date().toString(),
+ likes: 1,
+ pivot: {
+ user_uid: 1,
+ texture_tid: 2,
+ item_name: 'closet_cape',
+ },
+})
+
+const fixturePlayer: Readonly = Object.freeze({
+ pid: 1,
+ name: 'kumiko',
+ tid_skin: 1,
+ tid_cape: 2,
+})
+
+beforeEach(() => {
+ const container = document.createElement('div')
+ container.id = 'previewer'
+ document.body.appendChild(container)
+})
+
+afterEach(() => {
+ document.querySelector('#previewer')!.remove()
+})
+
+test('loading indicator', () => {
+ fetch.get.mockResolvedValue({
+ data: { items: [], category: 'skin', total_pages: 1 },
+ })
+ const { queryByTitle } = render()
+ expect(queryByTitle('Loading...')).toBeInTheDocument()
+})
+
+test('empty closet', async () => {
+ fetch.get.mockResolvedValue({
+ data: { items: [], category: 'skin', total_pages: 0 },
+ })
+
+ const { queryByText } = render()
+ await wait()
+ expect(queryByText(/skin library/i)).toBeInTheDocument()
+})
+
+test('categories', async () => {
+ fetch.get
+ .mockResolvedValueOnce({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+ .mockResolvedValueOnce({
+ data: { items: [fixtureCape], category: 'cape', total_pages: 1 },
+ })
+ .mockResolvedValueOnce({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+
+ const { getByText, queryByText } = render()
+ await wait()
+ expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
+ expect(queryByText(fixtureCape.pivot.item_name)).not.toBeInTheDocument()
+
+ fireEvent.click(getByText(t('general.cape')))
+ await wait()
+ expect(queryByText(fixtureSkin.pivot.item_name)).not.toBeInTheDocument()
+ expect(queryByText(fixtureCape.pivot.item_name)).toBeInTheDocument()
+
+ fireEvent.click(getByText(t('general.skin')))
+ await wait()
+ expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
+ expect(queryByText(fixtureCape.pivot.item_name)).not.toBeInTheDocument()
+})
+
+test('search textures', async () => {
+ fetch.get
+ .mockResolvedValueOnce({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+ .mockResolvedValueOnce({
+ data: { items: [], category: 'skin', total_pages: 0 },
+ })
+
+ const { getByPlaceholderText, queryByText } = render()
+ await wait()
+
+ fireEvent.input(getByPlaceholderText(t('user.typeToSearch')), {
+ target: { value: 'abc' },
+ })
+ await wait()
+
+ expect(queryByText(/no result/i)).toBeInTheDocument()
+})
+
+test('switch page', async () => {
+ fetch.get
+ .mockResolvedValueOnce({
+ data: { items: [], category: 'skin', total_pages: 2 },
+ })
+ .mockResolvedValueOnce({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 2 },
+ })
+
+ const { getByText, queryByText } = render()
+ await wait()
+ fireEvent.click(getByText('2'))
+ await wait()
+
+ expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
+})
+
+describe('rename item', () => {
+ beforeEach(() => {
+ fetch.get.mockResolvedValue({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+ })
+
+ it('succeeded', async () => {
+ fetch.post.mockResolvedValue({ code: 0, message: 'success' })
+
+ const { getByText, getByDisplayValue, getByRole, queryByText } = render(
+ ,
+ )
+ await wait()
+
+ fireEvent.click(getByText(t('user.renameItem')))
+ fireEvent.input(getByDisplayValue(fixtureSkin.pivot.item_name), {
+ target: { value: 'my skin' },
+ })
+ fireEvent.click(getByText(t('general.confirm')))
+ await wait()
+
+ expect(fetch.post).toBeCalledWith(
+ `/user/closet/rename/${fixtureSkin.tid}`,
+ { name: 'my skin' },
+ )
+ expect(queryByText('my skin')).toBeInTheDocument()
+ expect(queryByText('success')).toBeInTheDocument()
+ expect(getByRole('status')).toHaveClass('alert-success')
+ })
+
+ it('empty name', async () => {
+ fetch.post.mockResolvedValue({ code: 0, message: 'success' })
+
+ const { getByText, getByDisplayValue, queryByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('user.renameItem')))
+ fireEvent.input(getByDisplayValue(fixtureSkin.pivot.item_name), {
+ target: { value: '' },
+ })
+ fireEvent.click(getByText(t('general.confirm')))
+ await wait()
+
+ expect(fetch.post).not.toBeCalled()
+ expect(queryByText(t('skinlib.emptyNewTextureName'))).toBeInTheDocument()
+
+ fireEvent.click(getByText(t('general.cancel')))
+ })
+
+ it('failed', async () => {
+ fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
+
+ const { getByText, getByDisplayValue, getByRole, queryByText } = render(
+ ,
+ )
+ await wait()
+
+ fireEvent.click(getByText(t('user.renameItem')))
+ fireEvent.input(getByDisplayValue(fixtureSkin.pivot.item_name), {
+ target: { value: 'my skin' },
+ })
+ fireEvent.click(getByText(t('general.confirm')))
+ await wait()
+
+ expect(fetch.post).toBeCalledWith(
+ `/user/closet/rename/${fixtureSkin.tid}`,
+ { name: 'my skin' },
+ )
+ expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
+ expect(queryByText('failed')).toBeInTheDocument()
+ expect(getByRole('alert')).toHaveClass('alert-danger')
+ })
+
+ it('cancelled', async () => {
+ const { getByText, getByDisplayValue, queryByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('user.renameItem')))
+ fireEvent.input(getByDisplayValue(fixtureSkin.pivot.item_name), {
+ target: { value: 'my skin' },
+ })
+ fireEvent.click(getByText(t('general.cancel')))
+ await wait()
+
+ expect(fetch.post).not.toBeCalled()
+ expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
+ })
+})
+
+describe('remove item', () => {
+ beforeEach(() => {
+ fetch.get.mockResolvedValue({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+ })
+
+ it('succeeded', async () => {
+ fetch.post.mockResolvedValue({ code: 0, message: 'success' })
+
+ const { getByText, getByRole, queryByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('user.removeItem')))
+ fireEvent.click(getByText(t('general.confirm')))
+ await wait()
+
+ expect(fetch.post).toBeCalledWith(`/user/closet/remove/${fixtureSkin.tid}`)
+ expect(queryByText(/skin library/i)).toBeInTheDocument()
+ expect(queryByText('success')).toBeInTheDocument()
+ expect(getByRole('status')).toHaveClass('alert-success')
+ })
+
+ it('failed', async () => {
+ fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
+
+ const { getByText, getByRole, queryByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('user.removeItem')))
+ fireEvent.click(getByText(t('general.confirm')))
+ await wait()
+
+ expect(fetch.post).toBeCalledWith(`/user/closet/remove/${fixtureSkin.tid}`)
+ expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
+ expect(queryByText('failed')).toBeInTheDocument()
+ expect(getByRole('alert')).toHaveClass('alert-danger')
+ })
+
+ it('cancelled', async () => {
+ const { getByText, queryByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('user.removeItem')))
+ fireEvent.click(getByText(t('general.cancel')))
+ await wait()
+
+ expect(fetch.post).not.toBeCalled()
+ expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
+ })
+})
+
+describe('select textures', () => {
+ beforeEach(() => {
+ fetch.get
+ .mockResolvedValueOnce({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+ .mockResolvedValueOnce({
+ data: { items: [fixtureCape], category: 'cape', total_pages: 1 },
+ })
+ })
+
+ it('select skin', async () => {
+ const { getByAltText, queryAllByText } = render()
+ await wait()
+
+ fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
+ expect(queryAllByText(t('general.skin'))).toHaveLength(2)
+ })
+
+ it('select cape', async () => {
+ const { getByText, getByAltText, queryAllByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('general.cape')))
+ await wait()
+
+ fireEvent.click(getByAltText(fixtureCape.pivot.item_name))
+ expect(queryAllByText(t('general.cape'))).toHaveLength(2)
+ })
+
+ it('reset selected', async () => {
+ const { getByText, getByAltText, queryByText } = render()
+ await wait()
+
+ fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
+ fireEvent.click(getByText(t('general.cape')))
+ await wait()
+ fireEvent.click(getByAltText(fixtureCape.pivot.item_name))
+
+ expect(
+ queryByText(`${t('general.skin')} & ${t('general.cape')}`),
+ ).toBeInTheDocument()
+
+ fireEvent.click(getByText(t('user.resetSelected')))
+ expect(
+ queryByText(`${t('general.skin')} & ${t('general.cape')}`),
+ ).not.toBeInTheDocument()
+ })
+})
+
+describe('set avatar', () => {
+ beforeEach(() => {
+ fetch.get.mockResolvedValue({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+
+ const img = document.createElement('img')
+ img.alt = 'User Image'
+ document.body.appendChild(img)
+ })
+
+ afterEach(() => {
+ document.querySelector('[alt="User Image"]')!.remove()
+ })
+
+ it('succeeded', async () => {
+ fetch.post.mockResolvedValue({ code: 0, message: 'success' })
+
+ const { getByText, getByRole, queryByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('user.setAsAvatar')))
+ fireEvent.click(getByText(t('general.confirm')))
+ await wait()
+
+ expect(fetch.post).toBeCalledWith('/user/profile/avatar', {
+ tid: fixtureSkin.tid,
+ })
+ expect(queryByText('success')).toBeInTheDocument()
+ expect(getByRole('status')).toHaveClass('alert-success')
+ expect(document.querySelector('[alt="User Image"]')).toHaveAttribute('src')
+ })
+
+ it('failed', async () => {
+ fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
+
+ const { getByText, getByRole, queryByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('user.setAsAvatar')))
+ fireEvent.click(getByText(t('general.confirm')))
+ await wait()
+
+ expect(fetch.post).toBeCalledWith('/user/profile/avatar', {
+ tid: fixtureSkin.tid,
+ })
+ expect(queryByText('failed')).toBeInTheDocument()
+ expect(getByRole('alert')).toHaveClass('alert-danger')
+ })
+
+ it('cancelled', async () => {
+ const { getByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('user.setAsAvatar')))
+ fireEvent.click(getByText(t('general.cancel')))
+ await wait()
+
+ expect(fetch.post).not.toBeCalled()
+ })
+})
+
+describe('apply textures to player', () => {
+ it('selected nothing', async () => {
+ fetch.get.mockResolvedValue({
+ data: { items: [], category: 'skin', total_pages: 1 },
+ })
+
+ const { getByText, getByRole, queryByText } = render()
+ await wait()
+
+ fireEvent.click(getByText(t('user.useAs')))
+
+ expect(queryByText(t('user.emptySelectedTexture'))).toBeInTheDocument()
+ expect(getByRole('status')).toHaveClass('alert-info')
+ })
+
+ it('search players', async () => {
+ fetch.get
+ .mockResolvedValueOnce({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+ .mockResolvedValueOnce({ data: [fixturePlayer] })
+
+ const {
+ getByText,
+ getByAltText,
+ getAllByPlaceholderText,
+ queryByText,
+ } = render()
+ await wait()
+
+ fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
+ fireEvent.click(getByText(t('user.useAs')))
+
+ await wait()
+ expect(queryByText(fixturePlayer.name)).toBeInTheDocument()
+
+ fireEvent.input(getAllByPlaceholderText(t('user.typeToSearch'))[1], {
+ target: { value: 'reina' },
+ })
+ expect(queryByText(fixturePlayer.name)).not.toBeInTheDocument()
+ })
+
+ it('succeeded', async () => {
+ fetch.get
+ .mockResolvedValueOnce({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+ .mockResolvedValueOnce({ data: [fixturePlayer] })
+ fetch.post.mockResolvedValue({ code: 0, message: 'success' })
+
+ const {
+ getByText,
+ getByAltText,
+ getByTitle,
+ getByRole,
+ queryByText,
+ } = render()
+ await wait()
+
+ fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
+ fireEvent.click(getByText(t('user.useAs')))
+ await wait()
+ fireEvent.click(getByTitle(fixturePlayer.name))
+ await wait()
+
+ expect(fetch.post).toBeCalledWith(`/user/player/set/${fixturePlayer.pid}`, {
+ skin: fixtureSkin.tid,
+ })
+ expect(queryByText('success')).toBeInTheDocument()
+ expect(getByRole('status')).toHaveClass('alert-success')
+ })
+
+ it('failed', async () => {
+ fetch.get
+ .mockResolvedValueOnce({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+ .mockResolvedValueOnce({ data: [fixturePlayer] })
+ fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
+
+ const {
+ getByText,
+ getByAltText,
+ getByTitle,
+ getByRole,
+ queryByText,
+ } = render()
+ await wait()
+
+ fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
+ fireEvent.click(getByText(t('user.useAs')))
+ await wait()
+ fireEvent.click(getByTitle(fixturePlayer.name))
+ await wait()
+
+ expect(fetch.post).toBeCalledWith(`/user/player/set/${fixturePlayer.pid}`, {
+ skin: fixtureSkin.tid,
+ })
+ expect(queryByText('failed')).toBeInTheDocument()
+ expect(getByRole('alert')).toHaveClass('alert-danger')
+ })
+
+ it('close dialog', async () => {
+ fetch.get
+ .mockResolvedValueOnce({
+ data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
+ })
+ .mockResolvedValueOnce({ data: [fixturePlayer] })
+
+ const { getByText, getByAltText } = render()
+ await wait()
+
+ fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
+ fireEvent.click(getByText(t('user.useAs')))
+ await wait()
+
+ $('#modal-apply')
+ .modal('hide')
+ .trigger('hidden.bs.modal')
+
+ expect(fetch.post).not.toBeCalled()
+ })
+})
diff --git a/yarn.lock b/yarn.lock
index 5039ac26..599b3123 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1335,6 +1335,18 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==
+"@types/lodash.debounce@^4.0.6":
+ version "4.0.6"
+ resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60"
+ integrity sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ==
+ dependencies:
+ "@types/lodash" "*"
+
+"@types/lodash@*":
+ version "4.14.149"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
+ integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==
+
"@types/minimatch@*":
version "3.0.3"
resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@@ -6548,6 +6560,11 @@ lodash.clonedeep@^4.5.0:
resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
+lodash.debounce@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+ integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
+
lodash.filter@^4.6.0:
version "4.6.0"
resolved "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz#668b1d4981603ae1cc5a6fa760143e480b4c4ace"