Vue composable scoping issue - vue.js

We're using Vue 2 with the Vue Composition API and we're trying to create a composable that will expose application preferences:
// useApplicationPreferences.ts
import { ref, watch } from '#vue/composition-api'
import { useSetDarkModeMutation, useViewerQuery } from 'src/graphql/generated/operations'
const darkMode = ref(false) // global scope
export const useApplicationPreferences = () => {
const { mutate: darkModeMutation } = useSetDarkModeMutation(() => ({
variables: {
darkMode: darkMode.value,
},
}))
watch(darkMode, async (newDarkMode) => {
console.log('darkMode: ', newDarkMode)
await darkModeMutation()
})
return { darkMode }
}
This code works fine but when the composable is used in two components that are rendered at the same time we can see that watch has been triggered twice. This is easily solved by moving the watch function to the global scope (outside the function).
However, the issue then is that we can't use the darkModeMutation. This graphql mutation can not be moved to the global scope outside of the function, if we do that the page doesn't even get rendered.
The goal is to have darkMode available in many places and when the value of the darkMode ref changes the mutation is only triggered once. How can this be achieved?

Solved the issue by creating a callable function that starts watch only when required (i.e. only once somewhere in the app).
// useApplicationPreferences.ts
import { ref, watch } from '#vue/composition-api'
import { useSetDarkModeMutation, useViewerQuery } from 'src/graphql/generated/operations'
const darkMode = ref(false) // global scope
export const useApplicationPreferences = () => {
const { mutate: darkModeMutation } = useSetDarkModeMutation(() => ({
variables: {
darkMode: darkMode.value,
},
}))
const startWatch = () => {
watch(darkMode, async (newDarkMode) => {
await darkModeMutation()
})
}
return { darkMode, startWatch }
}
Which the can be called once in MainLayout.vue:
// MainLayout.vue
import { defineComponent } from '#vue/composition-api'
import { useApplicationPreferences } from 'useApplicationPreferences'
export default defineComponent({
setup() {
const { startWatch } = useApplicationPreferences()
startWatch()
},
})
All other components can then simply consume (get/set) the darkMode ref as required while watch is only running once.
// Settings.vue
import { defineComponent } from '#vue/composition-api'
import { useApplicationPreferences } from 'useApplicationPreferences'
export default defineComponent({
setup() {
const { darkMode } = useApplicationPreferences()
return { darkMode }
},
})

Related

Vue 3 vue-i18n. How to use $t(t) outside the app(.vue files)? Composition API

I want to format my date-time outside the component.
function transformDatetime(config, value) {
if (value) {
return $d(new Date(value), config);
}
return $t('-');
}
I'm trying to get $t from the App instance. But it only works in the context of the component, just like useI18n.
import { getCurrentInstance } from 'vue'
export default function useGlobal() {
const instance = getCurrentInstance()
if (!instance) return
return instance.appContext.config.globalProperties
}
I found a solution. Just import your i18n into a file outside the application and use i18n.global.t
import { createI18n } from "vue-i18n"
export const i18n = createI18n({
legacy: false,
locale: 'ja',
fallbackLocale: 'en',
messages,
})
import { i18n } from '#/i18n'
const { t: $t, d: $d, n: $n } = i18n.global
const expample = $t('some-text')
I think your method should also be a composable so you can simply use the method useI18n() inside.
Something like
use-transform-datetime.ts
import { useI18n } from 'vue-i18n'
export const useTransformDatetime = () => {
const { t } = useI18n()
const transformDatetime = (config, value) => {
if (value) {
return $d(new Date(value), config)
}
return t('-')
}
return {
transformDatetime
}
}
then simply use it in your vue file who need it
<script setup>
import { useTransformDatetime } from 'path-to-your-composables/use-transform-datetime'
const { transformDatetime } = useTransformDatetime()
console.log(transformDatetime(config, 'value'))
</script>
This way you can even add your others transform methods in the same composable
Try to use of the useI18n composable function from vue-i18n to get the t method instead of $t:
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
console.log(t('greeting'))
</script>

Slots not working in my VUE CustomElement (defineCustomElement)

I have created this initialization of CustomElement in VUE 3 from various sources on the web (doc's, stackoverflow, etc).
Unfortunately, nowhere was discussed how to deal with slots in this type of initialization.
If I understand it correctly, it should work according to the documentation.
https://vuejs.org/guide/extras/web-components.html#slots
import { defineCustomElement, h, createApp, getCurrentInstance } from "vue";
import audioplayer from "./my-audioplayer.ce.vue";
import audioplayerlight from "./my-audioplayerlight.ce.vue";
import { createPinia } from "pinia";
const pinia = createPinia();
export const defineCustomElementWrapped = (component, { plugins = [] } = {}) =>
defineCustomElement({
styles: component.styles,
props: component.props,
setup(props, { emit }) {
const app = createApp();
plugins.forEach((plugin) => {
app.use(plugin);
});
const inst = getCurrentInstance();
Object.assign(inst.appContext, app._context);
Object.assign(inst.provides, app._context.provides);
return () =>
h(component, {
...props,
});
},
});
customElements.define(
"my-audioplayer",
defineCustomElementWrapped(audioplayer, { plugins: [pinia] })
);
customElements.define(
"my-audioplayerlight",
defineCustomElementWrapped(audioplayerlight, { plugins: [pinia] })
);
I suspect that I forgot something during initialization and the contents of the slot are not passed on.
A little late, but we are working with this approach doing Web Components with Vue 3 and this workaround, adding Vue Component context to Custom Elements.
setup(props, { slots })
And then:
return () =>
h(component, {
...props,
...slots
});
Thanks #tony19, author of this workaround.

Vue3 testing composition API with vuex in vitest

I'm having trouble getting a mock action to run using Vue3 while testing with vitest.
I have a component which calls out to a modularized vuex store that is imported into my component using the composition api. Something like the following.
export default defineComponent({
setup() {
const { doAction } = useModActions([
'doAction'
])
}
})
I use createNamespacedHelpers to setup my store module from the vuex-composition-helpers library.
After I use useStore with a Symbol key to setup the state of my store. I consume it in my application by doing
app.use(store, key)
To mock it in my tests I was trying the following
const actions = {
doAction: vi.fn()
}
const spy = vi.spyOn(actions, 'doAction')
const mockStore = createStore({
modules: {
mod: {
namespaced: true,
actions
}
}
})
const wrapper = mount(Component, {
global: {
provide: { [key]: mockStore }
}
})
But my spy is never called and my component always calls the original implementation. Is there a way to get all these pieces working together?
The mockStore here (from Vuex's createStore()) is an instance of a Vue plugin, which should be passed to the global.plugins mounting option (not global.provide):
// MyComponent.spec.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '#vue/test-utils'
import { createStore } from 'vuex'
import MyComponent from '../MyComponent.vue'
describe('MyComponent', () => {
it('button calls doAction', async () => {
const actions = {
doAction: vi.fn(),
}
const mockStore = createStore({
modules: {
myModule: {
namespaced: true,
actions,
},
},
})
const wrapper = mount(MyComponent, {
global: {
plugins: [mockStore], // 👈
},
})
await wrapper.find("button").trigger("click")
expect(actions.doAction).toHaveBeenCalled()
})
})
demo

vue3 creating global variables returns undefined

I am currently working on upgrading a project to Vue3. Inside the project we have several files that create global variables inside a boot directory:
src/boot/auth.js
src/boot/axios.js
src/boot/cranky.js
....
Each of these files creates global variables that I am using throughout the project. For example my auth.js file:
import auth from '../app/auth';
import { createApp } from 'vue';
export default async ({
router,
Vue
}) => {
const app = createApp({});
app.config.globalProperties.$auth = auth;
// Vue.prototype.$auth = auth;
//This works with logic in MainLayout to permit users to see particular screens
//after they are logged in.
router.beforeResolve((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
// eslint-disable-next-line no-unused-vars
let user;
if (app.config.globalProperties.$auth.isAuthenticated()) {
// TODO: record screens viewed by router url
next();
} else {
next({
path: '/home'
});
}
}
next();
});
};
After research and reading I understand that global variables are created like this:
app.config.globalProperties.$auth = auth;, Which then should be called from other component files using this.$auth. In my case, however, this returns as undefined.
My theory is that there is an issue with my createapp. Am I calling that correctly, or am I missing something else?
Thanks.
EDIT
added code requested by #tony19
The following the full script tag. My understanding and I probably am wrong, is that the this works as a global in vue3.
<script>
import HeaderScoreIndicator from '../components/HeaderScoreIndicator.vue';
import ExitResearchControl from '../components/ExitResearchControl.vue';
import {mq} from 'src/boot/mq'
export default {
components: {
HeaderScoreIndicator,
ExitResearchControl
},
data: function () {
return {
tab: 'denial',
signedIn: false,
username: null,
user: null
};
},
computed: {
/*
These 4 functions bind the colors to the background elements
*/
title: function () {
return this.$store.state.globals.title;
},
toolbarStyle: function () {
return 'padding-top: 10px; background-color: ' + this.$store.state.globals.toolbarColor;
},
footerStyle: function () {
return `background-color: ${this.$store.state.globals.toolbarColor};`;
},
backgroundStyle: function () {
if(!this.$mq.phone){
return `background: linear-gradient(180deg, ${this.$store.state.globals.backgroundGradientTop} 0%, ${this.$store.state.globals.backgroundGradientBottom} 100%);
display: flex;
justify-content: center;`;
} else{
return `background: linear-gradient(180deg, ${this.$store.state.globals.backgroundGradientTop} 0%, ${this.$store.state.globals.backgroundGradientBottom} 100%);`;
};
},
limitSize: function(){
if(!this.$mq.phone){
return 'max-width: 1023px; width: 100%;'
} else{
return
};
}
},
/*
In the beforeCreate function, we're setting up an event listener to detect
when we've logged in. At the successful login we can push the user to
the correct screen.
*/
beforeCreate: function () {
console.log(this.$auth);
return this.$auth.getState().then(data => {
if (this.$auth.isAuthenticated()) {
this.username = this.$auth.getEmail();
return this.initialize(data).then(() => this.signedIn = true);
} else {
this.signedIn = false;
return newUserLanguageSelection()
}
}).catch(e => {
this.signedIn = false;
console.error(e);
});
},
methods: {
newUserLanguageSelection: function(){
if(localStorage.getItem('languageSet') != 'true'){
return this.$router.push('/language');
}
},
initialize: function (data) {
this.$store.commit('globals/dataLoaded');
this.$store.commit('scoring/setUsername', this.username);
return this.$store.dispatch('scoring/initializeScoring', { points: data.score | 0 })
.then(() => {
if(this.$store.state.globals.testQuiz){
return;
} else if(localStorage.getItem('languageSet') != 'true'){
return this.$router.push('/language');
}else if (data.seenAnalyticsDialog == true) {
return this.$router.push('/home');
} else {
return this.$router.push('/consent');
}
})
.catch(e => {
//silence NavigationDuplicated errors
if (e.name != "NavigationDuplicated")
throw e;
});
}
}
};
</script>
In the previous Vue 2 project, it looks like Vue was passed to the auth.js module so that it could attach $auth to the Vue prototype.
However, in an attempt to upgrade that particular code in the auth.js module to Vue 3, you ignore the Vue argument, and create a throwaway application instance to create the $auth global property:
// src/boot/auth.js
export default async ({
router,
Vue,
}) => {
const app = createApp({}); ❌ local app instance
app.config.globalProperties.$auth = auth;
⋮
}
But that won't work because the app instance is not somehow hooked into the application instance you'll eventually mount in main.js.
Fixing the upgrade
Your main.js probably looks similar to this:
// src/main.js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import auth from './boot/auth';
auth({ router, Vue });
⋮
Vue.createApp(App).mount('#app');
To correctly upgrade the original code, you should pass the app instance from main.js to auth.js:
// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import auth from './boot/auth';
const app = createApp(App);
auth({ router, app });
⋮
app.mount('#app');
And use that app in the auth.js module:
// src/boot/auth.js
export default async ({
router,
app, 👈
}) => {
👇
app.config.globalProperties.$auth = auth;
⋮
}
Convert to Vue plugin
Assuming you don't need to await the return of the auth.js module, you could make that module a Vue plugin instead, which automatically receives the app instance as the first argument. The second argument receives the options passed in from app.use():
// src/boot/auth.js
export default {
install(app, { router }) {
app.config.globalProperties.$auth = auth;
router.beforeResolve(⋯);
}
}
// src/main.js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import auth from './boot/auth';
const app = createApp(App);
app.use(auth, { router });
⋮
app.mount('#app');

How to use Pinia with Nuxt, composition-api (vue2) and SSR?

I'm trying to get Pinia to work in Nuxt with SSR (server-side rendering).
When creating a page without Pinia, it works:
<script>
import { reactive, useFetch, useContext } from '#nuxtjs/composition-api'
export default {
setup() {
const { $axios } = useContext()
const invitesStore = reactive({
invites: [],
loading: true,
})
useFetch(async () => {
invitesStore.loading = true
await $axios.$get('invite/registermember').then((result) => {
invitesStore.loading = false
invitesStore.invites = result.invites
})
})
return {
invitesStore,
}
},
}
</script>
But when introducing Pinia, I get the error "Converting circular structure to JSON --> starting at object with constructor 'VueRouter'"
I'm using Pinia this way:
// /store/invitesStore.js
import { defineStore } from 'pinia'
// useStore could be anything like useUser, useCart
export const useInvitesStore = defineStore({
// unique id of the store across your application
id: 'storeId',
state() {
return {
invites: [],
loading: true,
}
},
})
<script>
import { useInvitesStore } from '#/store/invitesStore'
import { reactive, onMounted, useFetch, useContext } from '#nuxtjs/composition-api'
export default {
setup() {
const { $axios } = useContext()
const invitesStore = useInvitesStore()
useFetch(async () => {
invitesStore.loading = true
await $axios.$get('invite/registermember').then((result) => {
invitesStore.loading = false
invitesStore.invites = result.invites
})
})
return {
invitesStore,
}
},
}
</script>
Is it possible to get this to work? How?