diff --git a/package.json b/package.json index 02d3058e..8cd68a35 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "admin-lte": "^3.0.1", "echarts": "^4.6.0", "jquery": "^3.4.1", + "lodash.debounce": "^4.0.8", "nanoid": "^2.1.11", "react": "^16.12.0", "react-dom": "^16.12.0", @@ -48,6 +49,7 @@ "@types/jest": "^24.0.25", "@types/jquery": "^3.3.29", "@types/js-yaml": "^3.12.2", + "@types/lodash.debounce": "^4.0.6", "@types/nanoid": "^2.1.0", "@types/react": "^16.9.17", "@types/react-dom": "^16.9.4", diff --git a/resources/assets/src/components/AddPlayerDialog.vue b/resources/assets/src/components/AddPlayerDialog.vue deleted file mode 100644 index 0f2e8aa1..00000000 --- a/resources/assets/src/components/AddPlayerDialog.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/resources/assets/src/components/ClosetItem.vue b/resources/assets/src/components/ClosetItem.vue deleted file mode 100644 index 0bb1cb8c..00000000 --- a/resources/assets/src/components/ClosetItem.vue +++ /dev/null @@ -1,130 +0,0 @@ - - - - - diff --git a/resources/assets/src/components/Viewer.tsx b/resources/assets/src/components/Viewer.tsx index 53b0b46e..faaa17ba 100644 --- a/resources/assets/src/components/Viewer.tsx +++ b/resources/assets/src/components/Viewer.tsx @@ -103,7 +103,11 @@ const Viewer: React.FC = props => { useEffect(() => { const viewer = viewRef.current - viewer.capeUrl = props.cape || '' + if (props.cape) { + viewer.capeUrl = props.cape + } else { + viewer.playerObject.cape.visible = false + } }, [props.cape]) useEffect(() => { @@ -138,7 +142,7 @@ const Viewer: React.FC = props => {

{trans('general.texturePreview')} {props.showIndicator && ( - {indicator} + {indicator} )}

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 ( + + ) +} + +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 += 'User Image' - - 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"