diff --git a/test/vitest/__tests__/boot/axios.spec.js b/src/boot/__tests__/axios.spec.js similarity index 100% rename from test/vitest/__tests__/boot/axios.spec.js rename to src/boot/__tests__/axios.spec.js diff --git a/src/components/CrudModel.vue b/src/components/CrudModel.vue index 7fdb54bc4..940b72ff0 100644 --- a/src/components/CrudModel.vue +++ b/src/components/CrudModel.vue @@ -127,7 +127,7 @@ function resetData(data) { originalData.value = JSON.parse(JSON.stringify(data)); formData.value = JSON.parse(JSON.stringify(data)); - if (watchChanges.value) watchChanges.value(); //destoy watcher + if (watchChanges.value) watchChanges.value(); //destroy watcher watchChanges.value = watch(formData, () => (hasChanges.value = true), { deep: true }); } @@ -270,10 +270,8 @@ function getChanges() { function isEmpty(obj) { if (obj == null) return true; - if (obj === undefined) return true; - if (Object.keys(obj).length === 0) return true; - - if (obj.length > 0) return false; + if (Array.isArray(obj)) return !obj.length; + return !Object.keys(obj).length; } async function reload(params) { diff --git a/src/components/LeftMenu.vue b/src/components/LeftMenu.vue index 31ad9ebed..7a882e56c 100644 --- a/src/components/LeftMenu.vue +++ b/src/components/LeftMenu.vue @@ -92,13 +92,13 @@ function findMatches(search, item) { } function addChildren(module, route, parent) { - if (route.menus) { - const mainMenus = route.menus[props.source]; - const matches = findMatches(mainMenus, route); + const menus = route?.meta?.menu ?? route?.menus?.[props.source]; //backwards compatible + if (!menus) return; - for (const child of matches) { - navigation.addMenuItem(module, child, parent); - } + const matches = findMatches(menus, route); + + for (const child of matches) { + navigation.addMenuItem(module, child, parent); } } @@ -122,16 +122,26 @@ function getRoutes() { if (props.source === 'card') { const currentRoute = route.matched[1]; const currentModule = toLowerCamel(currentRoute.name); - const moduleDef = routes.find( + let moduleDef = routes.find( (route) => toLowerCamel(route.name) === currentModule ); if (!moduleDef) return; - + if (!moduleDef?.menus) moduleDef = betaGetRoutes(); addChildren(currentModule, moduleDef, items.value); } } +function betaGetRoutes() { + let menuRoute; + let index = route.matched.length - 1; + while (!menuRoute && index > 0) { + if (route.matched[index]?.meta?.menu) menuRoute = route.matched[index]; + index--; + } + return menuRoute; +} + async function togglePinned(item, event) { if (event.defaultPrevented) return; event.preventDefault(); diff --git a/src/components/NavBar.vue b/src/components/NavBar.vue index 9b0393489..ef5bdc6ac 100644 --- a/src/components/NavBar.vue +++ b/src/components/NavBar.vue @@ -17,12 +17,10 @@ const stateQuery = useStateQueryStore(); const state = useState(); const user = state.getUser(); const appName = 'Lilium'; +const pinnedModulesRef = ref(); onMounted(() => stateStore.setMounted()); - -const pinnedModulesRef = ref(); - - - -en: - Go to Salix: Go to Salix -es: - Go to Salix: Ir a Salix - diff --git a/src/components/VnTable/VnFilter.vue b/src/components/VnTable/VnFilter.vue index 999133130..426f5c716 100644 --- a/src/components/VnTable/VnFilter.vue +++ b/src/components/VnTable/VnFilter.vue @@ -32,7 +32,10 @@ const $props = defineProps({ defineExpose({ addFilter, props: $props }); const model = defineModel(undefined, { required: true }); -const arrayData = useArrayData($props.dataKey, { searchUrl: $props.searchUrl }); +const arrayData = useArrayData( + $props.dataKey, + $props.searchUrl ? { searchUrl: $props.searchUrl } : null +); const columnFilter = computed(() => $props.column?.columnFilter); const updateEvent = { 'update:modelValue': addFilter }; diff --git a/src/components/VnTable/VnTable.vue b/src/components/VnTable/VnTable.vue index e78efa852..7886d0567 100644 --- a/src/components/VnTable/VnTable.vue +++ b/src/components/VnTable/VnTable.vue @@ -1,20 +1,21 @@ + diff --git a/test/vitest/__tests__/components/VnTable.spec.js b/src/components/VnTable/__tests__/VnTable.spec.js similarity index 82% rename from test/vitest/__tests__/components/VnTable.spec.js rename to src/components/VnTable/__tests__/VnTable.spec.js index 162df727d..74ba06987 100644 --- a/test/vitest/__tests__/components/VnTable.spec.js +++ b/src/components/VnTable/__tests__/VnTable.spec.js @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeAll, beforeEach } from 'vitest'; +import { describe, expect, it, beforeAll, beforeEach, vi } from 'vitest'; import { createWrapper } from 'app/test/vitest/helper'; import VnTable from 'src/components/VnTable/VnTable.vue'; @@ -13,6 +13,15 @@ describe('VnTable', () => { }, }); vm = wrapper.vm; + + vi.mock('src/composables/useFilterParams', () => { + return { + useFilterParams: vi.fn(() => ({ + params: {}, + orders: {}, + })), + }; + }); }); beforeEach(() => (vm.selected = [])); diff --git a/src/components/__tests__/CrudModel.spec.js b/src/components/__tests__/CrudModel.spec.js new file mode 100644 index 000000000..e0afd30ad --- /dev/null +++ b/src/components/__tests__/CrudModel.spec.js @@ -0,0 +1,248 @@ +import { createWrapper, axios } from 'app/test/vitest/helper'; +import CrudModel from 'components/CrudModel.vue'; +import { vi, afterEach, beforeEach, beforeAll, describe, expect, it } from 'vitest'; + +describe('CrudModel', () => { + let wrapper; + let vm; + let data; + beforeAll(() => { + wrapper = createWrapper(CrudModel, { + global: { + stubs: [ + 'vnPaginate', + 'useState', + 'arrayData', + 'useStateStore', + 'vue-i18n', + ], + mocks: { + validate: vi.fn(), + }, + }, + propsData: { + dataRequired: { + fk: 1, + }, + dataKey: 'crudModelKey', + model: 'crudModel', + url: 'crudModelUrl', + saveFn: '', + }, + }); + wrapper=wrapper.wrapper; + vm=wrapper.vm; + }); + + beforeEach(() => { + vm.fetch([]); + vm.watchChanges = null; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('insert()', () => { + it('should new element in list with index 0 if formData not has data', () => { + vm.insert(); + + expect(vm.formData.length).toEqual(1); + expect(vm.formData[0].fk).toEqual(1); + expect(vm.formData[0].$index).toEqual(0); + }); + }); + + describe('getChanges()', () => { + it('should return correct updates and creates', async () => { + vm.fetch([ + { id: 1, name: 'New name one' }, + { id: 2, name: 'New name two' }, + { id: 3, name: 'Bruce Wayne' }, + ]); + + vm.originalData = [ + { id: 1, name: 'Tony Starks' }, + { id: 2, name: 'Jessica Jones' }, + { id: 3, name: 'Bruce Wayne' }, + ]; + + vm.insert(); + const result = vm.getChanges(); + + const expected = { + creates: [ + { + $index: 3, + fk: 1, + }, + ], + updates: [ + { + data: { + name: 'New name one', + }, + where: { + id: 1, + }, + }, + { + data: { + name: 'New name two', + }, + where: { + id: 2, + }, + }, + ], + }; + + expect(result).toEqual(expected); + }); + }); + + describe('getDifferences()', () => { + it('should return the differences between two objects', async () => { + const obj1 = { + a: 1, + b: 2, + c: 3, + }; + const obj2 = { + a: null, + b: 4, + d: 5, + }; + + const result = vm.getDifferences(obj1, obj2); + + expect(result).toEqual({ + a: null, + b: 4, + d: 5, + }); + }); + }); + + describe('isEmpty()', () => { + let dummyObj; + let dummyArray; + let result; + it('should return true if object si null', async () => { + dummyObj = null; + result = vm.isEmpty(dummyObj); + + expect(result).toBe(true); + }); + + it('should return true if object si undefined', async () => { + dummyObj = undefined; + result = vm.isEmpty(dummyObj); + + expect(result).toBe(true); + }); + + it('should return true if object is empty', async () => { + dummyObj ={}; + result = vm.isEmpty(dummyObj); + + expect(result).toBe(true); + }); + + it('should return false if object is not empty', async () => { + dummyObj = {a:1, b:2, c:3}; + result = vm.isEmpty(dummyObj); + + expect(result).toBe(false); + }); + + it('should return true if array is empty', async () => { + dummyArray = []; + result = vm.isEmpty(dummyArray); + + expect(result).toBe(true); + }); + + it('should return false if array is not empty', async () => { + dummyArray = [1,2,3]; + result = vm.isEmpty(dummyArray); + + expect(result).toBe(false); + }) + }); + + describe('resetData()', () => { + it('should add $index to elements in data[] and sets originalData and formData with data', async () => { + data = [{ + name: 'Tony', + lastName: 'Stark', + age: 42, + }]; + + vm.resetData(data); + + expect(vm.originalData).toEqual(data); + expect(vm.originalData[0].$index).toEqual(0); + expect(vm.formData).toEqual(data); + expect(vm.formData[0].$index).toEqual(0); + expect(vm.watchChanges).not.toBeNull(); + }); + + it('should dont do nothing if data is null', async () => { + vm.resetData(null); + + expect(vm.watchChanges).toBeNull(); + }); + + it('should set originalData and formatData with data and generate watchChanges', async () => { + data = { + name: 'Tony', + lastName: 'Stark', + age: 42, + }; + + vm.resetData(data); + + expect(vm.originalData).toEqual(data); + expect(vm.formData).toEqual(data); + expect(vm.watchChanges).not.toBeNull(); + }); + }); + + describe('saveChanges()', () => { + data = [{ + name: 'Tony', + lastName: 'Stark', + age: 42, + }]; + + it('should call saveFn if exists', async () => { + await wrapper.setProps({ saveFn: vi.fn() }); + + vm.saveChanges(data); + + expect(vm.saveFn).toHaveBeenCalledOnce(); + expect(vm.isLoading).toBe(false); + expect(vm.hasChanges).toBe(false); + + await wrapper.setProps({ saveFn: '' }); + }); + + it("should use default url if there's not saveFn", async () => { + const postMock =vi.spyOn(axios, 'post'); + + vm.formData = [{ + name: 'Bruce', + lastName: 'Wayne', + age: 45, + }] + + await vm.saveChanges(data); + + expect(postMock).toHaveBeenCalledWith(vm.url + '/crud', data); + expect(vm.isLoading).toBe(false); + expect(vm.hasChanges).toBe(false); + expect(vm.originalData).toEqual(JSON.parse(JSON.stringify(vm.formData))); + }); + }); +}); diff --git a/src/components/__tests__/FormModel.spec.js b/src/components/__tests__/FormModel.spec.js new file mode 100644 index 000000000..e35684bc3 --- /dev/null +++ b/src/components/__tests__/FormModel.spec.js @@ -0,0 +1,149 @@ +import { describe, expect, it, beforeAll, vi, afterAll } from 'vitest'; +import { createWrapper, axios } from 'app/test/vitest/helper'; +import FormModel from 'src/components/FormModel.vue'; + +describe('FormModel', () => { + const model = 'mockModel'; + const url = 'mockUrl'; + const formInitialData = { mockKey: 'mockVal' }; + + describe('modelValue', () => { + it('should use the provided model', () => { + const { vm } = mount({ propsData: { model } }); + expect(vm.modelValue).toBe(model); + }); + + it('should use the route meta title when model is not provided', () => { + const { vm } = mount({}); + expect(vm.modelValue).toBe('formModel_mockTitle'); + }); + }); + + describe('onMounted()', () => { + let mockGet; + + beforeAll(() => { + mockGet = vi.spyOn(axios, 'get').mockResolvedValue({ data: {} }); + }); + + afterAll(() => { + mockGet.mockRestore(); + }); + + it('should not fetch when has formInitialData', () => { + mount({ propsData: { url, model, autoLoad: true, formInitialData } }); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('should fetch when there is url and auto-load', () => { + mount({ propsData: { url, model, autoLoad: true } }); + expect(mockGet).toHaveBeenCalled(); + }); + + it('should not observe changes', () => { + const { vm } = mount({ + propsData: { url, model, observeFormChanges: false, formInitialData }, + }); + + expect(vm.hasChanges).toBe(true); + vm.reset(); + expect(vm.hasChanges).toBe(true); + }); + + it('should observe changes', async () => { + const { vm } = mount({ + propsData: { url, model, formInitialData }, + }); + vm.state.set(model, formInitialData); + expect(vm.hasChanges).toBe(false); + + vm.formData.mockKey = 'newVal'; + await vm.$nextTick(); + expect(vm.hasChanges).toBe(true); + vm.formData.mockKey = 'mockVal'; + }); + }); + + describe('trimData()', () => { + let vm; + beforeAll(() => { + vm = mount({}).vm; + }); + + it('should trim whitespace from string values', () => { + const data = { key1: ' value1 ', key2: ' value2 ' }; + const trimmedData = vm.trimData(data); + expect(trimmedData).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + it('should not modify non-string values', () => { + const data = { key1: 123, key2: true, key3: null, key4: undefined }; + const trimmedData = vm.trimData(data); + expect(trimmedData).toEqual(data); + }); + }); + + describe('save()', async () => { + it('should not call if there are not changes', async () => { + const { vm } = mount({ propsData: { url, model } }); + + await vm.save(); + expect(vm.hasChanges).toBe(false); + }); + + it('should call axios.patch with the right data', async () => { + const spy = vi.spyOn(axios, 'patch').mockResolvedValue({ data: {} }); + const { vm } = mount({ propsData: { url, model, formInitialData } }); + vm.formData.mockKey = 'newVal'; + await vm.$nextTick(); + await vm.save(); + expect(spy).toHaveBeenCalled(); + vm.formData.mockKey = 'mockVal'; + }); + + it('should call axios.post with the right data', async () => { + const spy = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} }); + const { vm } = mount({ + propsData: { url, model, formInitialData, urlCreate: 'mockUrlCreate' }, + }); + vm.formData.mockKey = 'newVal'; + await vm.$nextTick(); + await vm.save(); + expect(spy).toHaveBeenCalled(); + vm.formData.mockKey = 'mockVal'; + }); + + it('should use the saveFn', async () => { + const { vm } = mount({ + propsData: { url, model, formInitialData, saveFn: () => {} }, + }); + const spyPatch = vi.spyOn(axios, 'patch').mockResolvedValue({ data: {} }); + const spySaveFn = vi.spyOn(vm.$props, 'saveFn'); + + vm.formData.mockKey = 'newVal'; + await vm.$nextTick(); + await vm.save(); + expect(spyPatch).not.toHaveBeenCalled(); + expect(spySaveFn).toHaveBeenCalled(); + vm.formData.mockKey = 'mockVal'; + }); + + it('should reload the data after save', async () => { + const { vm } = mount({ + propsData: { url, model, formInitialData, reload: true }, + }); + vi.spyOn(axios, 'patch').mockResolvedValue({ data: {} }); + + vm.formData.mockKey = 'newVal'; + await vm.$nextTick(); + await vm.save(); + vm.formData.mockKey = 'mockVal'; + }); + }); +}); + +function mount({ propsData = {} }) { + return createWrapper(FormModel, { + propsData, + }); +} diff --git a/test/vitest/__tests__/components/Leftmenu.spec.js b/src/components/__tests__/Leftmenu.spec.js similarity index 100% rename from test/vitest/__tests__/components/Leftmenu.spec.js rename to src/components/__tests__/Leftmenu.spec.js diff --git a/src/components/common/RightMenu.vue b/src/components/common/RightMenu.vue index 3aa1891f9..32dc2874d 100644 --- a/src/components/common/RightMenu.vue +++ b/src/components/common/RightMenu.vue @@ -2,7 +2,11 @@ import { ref, onMounted, useSlots } from 'vue'; import { useI18n } from 'vue-i18n'; import { useStateStore } from 'stores/useStateStore'; +import { useQuasar } from 'quasar'; +const { t } = useI18n(); +const quasar = useQuasar(); +const stateStore = useStateStore(); const slots = useSlots(); const hasContent = ref(false); const rightPanel = ref(null); @@ -11,7 +15,6 @@ onMounted(() => { rightPanel.value = document.querySelector('#right-panel'); if (!rightPanel.value) return; - // Check if there's content to display const observer = new MutationObserver(() => { hasContent.value = rightPanel.value.childNodes.length; }); @@ -21,12 +24,9 @@ onMounted(() => { childList: true, attributes: true, }); - - if (!slots['right-panel'] && !hasContent.value) stateStore.rightDrawer = false; + if ((!slots['right-panel'] && !hasContent.value) || quasar.platform.is.mobile) + stateStore.rightDrawer = false; }); - -const { t } = useI18n(); -const stateStore = useStateStore(); - es: Open date: Abrir fecha diff --git a/src/components/common/VnInputTime.vue b/src/components/common/VnInputTime.vue index 6724c00b5..b4b246618 100644 --- a/src/components/common/VnInputTime.vue +++ b/src/components/common/VnInputTime.vue @@ -80,7 +80,7 @@ function dateToTime(newDate) { :class="{ required: isRequired }" style="min-width: 100px" :rules="mixinRules" - @click="isPopupOpen = false" + @click="isPopupOpen = !isPopupOpen" type="time" hide-bottom-space > @@ -100,12 +100,6 @@ function dateToTime(newDate) { isPopupOpen = false; " /> - obj.country?.name, ]; -const formatLocation = (obj, properties) => { +const formatLocation = (obj, properties = locationProperties) => { const parts = properties.map((prop) => { if (typeof prop === 'string') { return obj[prop]; diff --git a/src/components/common/VnSectionMain.vue b/src/components/common/VnModule.vue similarity index 52% rename from src/components/common/VnSectionMain.vue rename to src/components/common/VnModule.vue index 15be6ad9a..505b3a8b5 100644 --- a/src/components/common/VnSectionMain.vue +++ b/src/components/common/VnModule.vue @@ -1,8 +1,8 @@