diff --git a/resources/assets/src/components/user/ClosetItem.vue b/resources/assets/src/components/user/ClosetItem.vue
new file mode 100644
index 00000000..eb4484e2
--- /dev/null
+++ b/resources/assets/src/components/user/ClosetItem.vue
@@ -0,0 +1,141 @@
+
+
+
+
![]()
+
+
+
+
+
+
diff --git a/resources/assets/tests/components/user/ClosetItem.test.js b/resources/assets/tests/components/user/ClosetItem.test.js
new file mode 100644
index 00000000..4a98eea1
--- /dev/null
+++ b/resources/assets/tests/components/user/ClosetItem.test.js
@@ -0,0 +1,117 @@
+import { mount } from '@vue/test-utils';
+import ClosetItem from '@/user/ClosetItem';
+import axios from 'axios';
+import swal from 'sweetalert2';
+
+jest.mock('axios');
+jest.mock('sweetalert2');
+
+window.blessing = {
+ base_url: ''
+};
+
+function factory(opt = {}) {
+ return {
+ tid: 1,
+ name: 'texture',
+ type: 'steve',
+ ...opt
+ };
+}
+
+beforeEach(() => {
+ axios.post.mockReset();
+ swal.mockReset();
+});
+
+test('computed values', () => {
+ const wrapper = mount(ClosetItem, { propsData: factory() });
+ expect(wrapper.find('img').attributes().src).toBe('/preview/1.png');
+ expect(wrapper.find('a.more').attributes().href).toBe('/skinlib/show/1');
+});
+
+test('selected item', () => {
+ const wrapper = mount(ClosetItem, { propsData: factory({ selected: true }) });
+ expect(wrapper.find('.item').classes()).toContain('item-selected');
+});
+
+test('click item body', () => {
+ const wrapper = mount(ClosetItem, { propsData: factory() });
+
+ wrapper.find('.item').trigger('click');
+ expect(wrapper.emitted().select).toBeUndefined();
+
+ wrapper.find('.item-body').trigger('click');
+ expect(wrapper.emitted().select).toBeTruthy();
+});
+
+test('rename texture', async () => {
+ axios.post
+ .mockResolvedValueOnce({ data: { errno: 0 } })
+ .mockResolvedValueOnce({ data: { errno: 1 } });
+ swal.mockImplementation(async options => {
+ options.inputValidator('name');
+ options.inputValidator().catch(() => {});
+ return 'new-name';
+ });
+
+ const wrapper = mount(ClosetItem, { propsData: factory() });
+ const button = wrapper.findAll('.dropdown-menu > li').at(0).find('a');
+
+ button.trigger('click');
+ await wrapper.vm.$nextTick();
+
+ button.trigger('click');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('.texture-name > span').text()).toBe('new-name (steve)');
+ expect(axios.post).toBeCalledWith(
+ '/user/closet/rename',
+ { tid: 1, new_name: 'new-name' }
+ );
+});
+
+test('remove texture', async () => {
+ axios.post
+ .mockResolvedValueOnce({ data: { errno: 0 } })
+ .mockResolvedValueOnce({ data: { errno: 1 } });
+ swal.mockResolvedValue();
+
+ const wrapper = mount(ClosetItem, { propsData: factory() });
+ const button = wrapper.findAll('.dropdown-menu > li').at(1).find('a');
+
+ button.trigger('click');
+ await wrapper.vm.$nextTick();
+
+ button.trigger('click');
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted()['item-removed'][0][0]).toBe(1);
+ expect(axios.post).toBeCalledWith('/user/closet/remove', { tid: 1 });
+});
+
+test('set as avatar', async () => {
+ axios.post
+ .mockResolvedValueOnce({ data: { errno: 0 } })
+ .mockResolvedValueOnce({ data: { errno: 1 } });
+ swal.mockResolvedValue();
+ window.$ = jest.fn(() => ({
+ each(fn) { fn(); },
+ prop() {},
+ attr() { return ''; }
+ }));
+
+ const wrapper = mount(ClosetItem, { propsData: factory() });
+ const button = wrapper.findAll('.dropdown-menu > li').at(2).find('a');
+
+ button.trigger('click');
+ await wrapper.vm.$nextTick();
+
+ button.trigger('click');
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.$nextTick();
+ expect(axios.post).toBeCalledWith('/user/profile/avatar', { tid: 1 });
+ expect(window.$).toBeCalledWith('[alt="User Image"]');
+});