What's the most idomatic Vue way of handling this higher-order component? - vue.js

I have a VueJS organization and architecture question. I have a bunch of pages that serve as CRUD pages for individual objects in my system. They share a lot of code . I've abstracted this away in a shared component but I don't love what I did and I'm looking for advice on the idiomatic Vue way to do this.
I'm trying to create a higher order component that accepts two arguments:
An edit component that is the editable view of each object. So you can think of it as a stand in for a text input with a v-model accept that it has tons of different inputs.
A list component which displays a list of all the objects for that type in the system. It controls navigation to edit that object.
Normally this would be simply something where I use slots and invoke this component in the view page for each CRUD object. So basically I'd have something like this:
<!-- this file is src/views/DogsCRUDPage.vue -->
<template>
<general-crud-component
name="dogs"
backendurl="/dogs/"
>
<template slot="list">
<dogs-list-component />
</template>
<template slot="edit">
<dogs-edit-field v-model="... oops .." />
</template>
</general-crud-component>
</template>
<script>
export default {
name: "DogCRUDPage",
components: {
GeneralCrudComponent,
DogListComponent,
DogEditField,
},
}
</script>
This is nice because it matches the general syntax of all of my other VueJS pages and how I pass props and things to shared code. However, the problem is that GeneralCRUDComponent handles all of the mechanisms for checking if an object is edited, and therefor hiding or unhiding the save button, etc. Therefor it has the editable object in its data which will become the v-model for DogsEditField or any other that's passed to it. So it needs to pass this component a prop. So what I've done this:
// This file is src/utils/crud.js
import Vue from "vue"
const crudView = (listComponent, editComponent) => {
return Vue.component('CrudView', {
template: `
<v-row>
<list-component />
<v-form>
<edit-component v-model="obj" />
</v-form>
</v-row>
`,
components: {
ListComponent: listComponent,
EditComponent: editComponent,
},
data() {
return {
obj: {},
}
},
})
}
export default crudView
This file has a ton of shared code not shown that is doing the nuts and bolts of editing, undo, saving, etc.
And then in my src/router/index.js
//import DogCRUDPage from "#/views/libs/DogCRUDPage"
import crudView from "#/utils/crud"
import DogListComponent from "#/components/DogListComponent"
import DogEditField from "#/components/design/DogEditField"
const DogCRUDPage = crudView(DesignBasisList, DesignBasis)
Vue.use(VueRouter);
export default new VueRouter({
routes: [
{
path: "/dog",
name: "dog",
component: DogCRUDPage,
},
})
This is working, but there are issues I don't love about it. For one, I needed to enable runtimecompiler for my project which increases the size of the payload to the browser. I need to import the list and edit components to my router instead of just the page for every single object I have a page for. The syntax for this new shared component is totally different from the template syntax all the other pages use. It puts all of my page creation into the router/index.js file instead of just layed out as files in src/views which I am used to in Vue.
What is the idiomatic way to accomplish this? Am I on the right track here? I'm happy to do this, it's working, if this really is how we do this in Vue. But I would love to explore alternatives if the Vue community does something differently. I guess I'm mostly looking for the idiomatic Vue way to accomplish this. Thanks a bunch.

How about this:
DogsPage.vue
<template>
<CrudView
:editComponent="DogsEdit"
:listComponent="DogsList"
></CrudView>
</template>
<script>
import DogsEdit from '#/components/DogsEdit.vue'
import DogsList from '#/components/DogsList.vue'
import CrudView from '#/components/CrudView.vue'
export default {
components: { CrudView },
data() {
return { DogsEdit, DogsList }
}
}
</script>
CrudView.vue
<template>
<div>
<component :is="listComponent"></component>
<component :is="editComponent" v-model="obj"></component>
</div>
</template>
<script>
export default {
props: {
editComponent: Object,
listComponent: Object
},
data() {
return {
obj: {}
}
}
}
</script>

Related

Paginated async Component doesn't trigger setup() on route change

I have a paginated component. The async setup() method is pulling data from an API to populate the page. It works fine when the route is directly loaded, but when I change the route to a different page slug (eg. clicking a router-link), the component is not reloaded and setup is not executed again to fetch the new data.
I guess I somehow want to force reloading the component?
This is my MainApp component it has the router view and fallback.
<router-view v-slot="{ Component }">
<Suspense>
<template #default>
<component :is="Component" />
</template>
<template #fallback>
loading...
</template>
</Suspense>
</router-view>
The router looks kinda like that. You see the page component takes a page_slug:
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "",
component: MainApp,
children: [
{
name: "page",
path: "page/:page_slug",
component: Page,
props: true,
},
// [...]
]
}
And this is how my Page component looks like. It uses the page_slug to load data from an API which is then used in the template:
<template>
<div> {{ pageData }} </div>
</template>
export default defineComponent({
name: "Page",
props: {
page_slug: {
type: String,
required: true,
},
},
async setup(props) {
const pageData = await store.dispatch("getPageData", {
page_slug: props.page_slug
});
return { pageData }
}
}
When I directly open the route, the fallback "loading..." is nicely shown until the data is returned and the component is rendered.
But when I do a route change to another page, then async setup() is not executed again. In that case the url in the browser updates, but the data just remains the same.
How can I solve this case? Do I have to force reload the component somehow? Or have an entirely different architecture to the data loading?
The answer is simple, when trying to create Vue 3 Single File Components (SFCs) in Composition API way as shown below:
<template>
<!-- Your HTML code-->
</template>
<script>
export default {
name: 'ComponentName',
async setup():{
// Your code
}
};
</script>
<style>
/*Your Style Code*/
</style>
<script>, will only executes once when the component is first imported. So, when the data have changed by other component, the component above will not updated or in other words not re-created.
To make your component re-created whenever it about to mount, you have to use <script setup> which will make sure the code inside will execute every time an instance of the component is created, but you need to re-write your script code with few changes in comparison when using setup() method, and also you are able to use both of scripts like this:
<script>
// normal <script>, executed in module scope (only once)
runSideEffectOnce()
// declare additional options
export default {
name: "ComponentName",
inheritAttrs: false,
customOptions: {}
}
</script>
<script setup>
// executed in setup() scope (for each instance)
</script>
Read this documentation carefully to have full idea.

Unique EventBus for multiple instances of the same Vue application

I have a Vue application (which is basically a video player) that uses EventBus to communicate across components which normally cannot communicate easily. This worked perfectly when I was developing the video player, but now I have bundled it using Rollup, and when I try to put multiple instances of the video player on the same page, any Event one instance sends will also be picked up by all the other instances of the application.
Now in hindsight I understand why people don't seem to like the EventBus, but I can't find a great solution to either improve or replace it. I can't name the EventBus instances dynamically, because then the rest of my application won't be informed about the new name. I can't even use something like a videoId in my EventBus listeners to control the uniqueness, because then I will encounter the same problem if a video is on the same page more than once.
Some posts suggest VueX, but my app doesn't need to be stateful and it doesn't seem like a replacement for Event and listeners (though I could be wrong on that.) It seems like a lot of overhead for more functionality than I need. Again, I could be wrong.
I tried to remove as much irrelevant code as possible, but to give an idea of how I implemented my EventBus:
event-bus.js:
import Vue from 'vue';
const EventBus = new Vue();
export default EventBus;
MediaPlayer.vue:
<template>
<div>
<div>
<div id='media-player'>
<end-screen v-if="videoIsFinished"/>
<tap-video
ref="tapVideoRef"
:source='sourceUrl'
:videoId='videoId'
#videoEndHandler='videoEndHandler'
>
</tap-video>
<div id="control-bar-container">
<transition name="slide-fade">
<div v-show='(showControls || !playing)' >
<media-controls
:playing="playing"
:videoLength="videoLength"
/>
</div>
</transition>
</div>
</div>
</div>
</div>
</template>
<script>
import TapVideo from './TapVideo.vue';
import EventBus from './event-bus';
export default {
data (){
return{
playing: false,
showControls: false,
videoLength: 0,
tapVideoRef: null
}
},
mounted() {
this.tapVideoRef = this.$refs.tapVideoRef;
EventBus.$on('videoLoaded', videoLength => {
this.videoLength = videoLength;
});
EventBus.$on('playStateChange', playing => {
this.onPlayStateChange(playing);
});
},
beforeDestroy() {
EventBus.$off(['playStateChange','closeDrawer']);
},
props: ['sourceUrl', 'platformType', 'videoId'],
}
</script>
In case anyone comes across this problem, I found a solution that works well for me. Thanks to #ChunbinLi for pointing me in the right direction - their solution did work, but passing props everywhere is a bit clunky.
Instead, I used the Provide/Inject pattern supported by Vue: https://v3.vuejs.org/guide/component-provide-inject.html
Some minimal relevant code:
The highest level Grandparent will provide the EventBus,
Grandparent.vue
export default {
provide() {
return {
eventBus: new Vue()
}
}
}
Then any descendant has the ability to Inject that bus,
Parent.vue
export default {
inject: ['eventBus'],
created() {
this.eventBus.$emit('neededEvent')
}
}
Child.vue
export default {
inject: ['eventBus'],
created(){
this.eventBus.$on('neededEvent', ()=>{console.log('Event triggered!')});
}
}
This is still a GLOBAL EventBus, so directionality of events and parental relationship is easy, as long as all components communicating are descendants of the component which "Provided" the bus.

Vue manually mounting & remounting components

I have the following stripped down code that dynamically mounts components from a dropdown list:
<template>
<v-app>
<v-container>
<v-layout>
<v-select label="Providers"
single-line
:items="providers"
item-text="txt"
item-value="val"
:v-model="provider"
v-on:change="setProvider" />
<div ref='provider' id='provider' />
</v-layout>
</v-container>
</v-app>
</template>
<script>
import Provider1 from './components/Provider'
import Provider2 from './components/Provider2'
import Vue from 'vue'
import vuetify from './plugins/vuetify';
export default {
data: () => {
return {
provider: null,
providers: [
{txt: 'a', val: Provider1},
{txt: 'b', val: Provider2}
],
};
},
methods: {
setProvider(val) {
console.log(this.$refs.provider);
if (this.provider) {
// unmount and/or re-create #provider dom element
}
this.provider = new (Vue.extend(val))({
vuetify,
}).$mount('#provider');
}
},
}
</script>
First selection works great, subsequent selections graces my console window with "[Vue warn]: Cannot find element: #provider"
What should be placed in // unmount and/or re-create #provider dom element?
Also, if these need to be separately created questions, let me know:
What happens to the dom element? It doesn't get replaced as console.log(this.$refs.provider); clearly shows.
Why is manually mounting components advised against everywhere by everyone? Pending info on the unmount code, this way of doing it looks much more elegant than a slough of v-ifs would look in my opinion.
(edit: added 3rd question)
Are there any downsides to mixing vanilla markup with Vuetify's such as the above <div />?
Thanks
(edit: revised, working code. I've added an emit for extra fun)
<template>
<v-app>
<v-app-bar app />
<v-main>
<v-select label="Providers"
:items="providers"
v-model="provider" />
<component :is="provider" #fb="feedback" />
</v-main>
</v-app>
</template>
<script>
import Provider1 from './components/Provider'
import Provider2 from './components/Provider2'
export default {
data: () => {
return {
provider: null,
providers: [
{text: 'a', value: Provider1},
{text: 'b', value: Provider2}
],
};
},
methods: {
feedback(v) {
alert(v);
}
}
}
</script>
If your objective is to change between components on-the-fly, you can use the is Vue keyword to build dynamic components. That way you won't need to use v-ifs to control which component must render.
I'm also pretty sure you're not supposed to $mount inside components I believe that causes some side-effects and isn't generally good practice, since there are at least other ways to do it.
About mixing Vuetify and vanilla HTML, there's mostly no problem there. Some of Vuetify's selectors are pretty specific (like using scrollable in a v-dialog with v-card) but most are more general.

Why is my `client-only` component in nuxt complaining that `window is not defined`?

I have Vue SPA that I'm trying to migrate to nuxt. I am using vue2leaflet in a component that I enclosed in <client-only> tags but still getting an error from nuxt saying that window is not defined.
I know I could use nuxt-leaflet or create a plugin but that increases the vendor bundle dramatically and I don't want that. I want to import the leaflet plugin only for the components that need it. Any way to do this?
<client-only>
<map></map>
</client-only>
And the map component:
<template>
<div id="map-container">
<l-map
style="height: 80%; width: 100%"
:zoom="zoom"
:center="center"
#update:zoom="zoomUpdated"
#update:center="centerUpdated"
#update:bounds="boundsUpdated"
>
<l-tile-layer :url="url"></l-tile-layer>
</l-map>
</div>
</template>
<script>
import {
LMap,
LTileLayer,
LMarker,
LFeatureGroup,
LGeoJson,
LPolyline,
LPolygon,
LControlScale
} from 'vue2-leaflet';
import { Icon } from 'leaflet';
import 'leaflet/dist/leaflet.css';
// this part resolve an issue where the markers would not appear
delete Icon.Default.prototype._getIconUrl;
export default {
name: 'map',
components: {
LMap,
LTileLayer,
LMarker,
LFeatureGroup,
LGeoJson,
LPolyline,
LPolygon,
LControlScale
},
//...
I found a way that works though I'm not sure how. In the parent component, you move the import statement inside component declarations.
<template>
<client-only>
<map/>
</client-only>
</template>
<script>
export default {
name: 'parent-component',
components: {
Map: () => if(process.client){return import('../components/Map.vue')},
},
}
</script>
<template>
<client-only>
<map/>
</client-only>
</template>
<script>
export default {
name: 'parent-component',
components: {
Map: () =>
if (process.client) {
return import ('../components/Map.vue')
},
},
}
</script>
The solutions above did not work for me.
Why? This took me a while to find out so I hope it helps someone else.
The "problem" is that Nuxt automatically includes Components from the "components" folder so you don't have to include them manually. This means that even if you load it dynamically only on process.client it will still load it server side due to this automatism.
I have found the following two solutions:
Rename the "components" folder to something else to stop the automatic import and then use the solution above (process.client).
(and better option IMO) there is yet another feature to lazy load the automatically loaded components. To do this prefix the component name with "lazy-". This, in combination with will prevent the component from being rendered server-side.
In the end your setup should look like this
Files:
./components/map.vue
./pages/index.html
index.html:
<template>
<client-only>
<lazy-map/>
</client-only>
</template>
<script>
export default {
}
</script>
The <client-only> component doesn’t do what you think it does. Yes, it skips rendering your component on the server side, but it still gets executed!
https://deltener.com/blog/common-problems-with-the-nuxt-client-only-component/
Answers here are more focused towards import the Map.vue component while the best approach is probably to properly load the leaflet package initially inside of Map.vue.
Here, the best solution would be to load the components like so in Map.vue
<template>
<div id="map-container">
<l-map style="height: 80%; width: 100%">
<l-tile-layer :url="url"></l-tile-layer>
</l-map>
</div>
</template>
<script>
import 'leaflet/dist/leaflet.css'
export default {
name: 'Map',
components: {
[process.client && 'LMap']: () => import('vue2-leaflet').LMap,
[process.client && 'LTileLayer']: () => import('vue2-leaflet').LTileLayer,
},
}
</script>
I'm not a leaflet expert, hence I'm not sure if Leaflet care if you import it like import('vue2-leaflet').LMap but looking at this issue, it looks like it doesn't change a lot performance-wise.
Using Nuxt plugins is NOT a good idea as explained by OP because it will increase the whole bundle size upfront. Meaning that it will increase the loading time of your whole application while the Map is being used only in one place.
My How to fix navigator / window / document is undefined in Nuxt answer goes a bit more in detail about this topic and alternative approaches to solve this kind of issues.
Especially if you want to import a single library like vue2-editor, jsplumb or alike.
Here is how I do it with Nuxt in Universal mode:
this will: 1. Work with SSR
2. Throw no errors related to missing marker-images/shadow
3. Make sure leaflet is loaded only where it's needed (meaning no plugin is needed)
4. Allow for custom icon settings etc
5. Allow for some plugins (they were a pain, for some reason I thought you could just add them as plugins.. turns out adding them to plugins would defeat the local import of leaflet and force it to be bundled with vendors.js)
Wrap your template in <client-only></client-only>
<script>
let LMap, LTileLayer, LMarker, LPopup, LIcon, LControlAttribution, LControlZoom, Vue2LeafletMarkerCluster, Icon
if (process.client) {
require("leaflet");
({
LMap,
LTileLayer,
LMarker,
LPopup,
LIcon,
LControlAttribution,
LControlZoom,
} = require("vue2-leaflet/dist/vue2-leaflet.min"));
({
Icon
} = require("leaflet"));
Vue2LeafletMarkerCluster = require('vue2-leaflet-markercluster')
}
import "leaflet/dist/leaflet.css";
export default {
components: {
"l-map": LMap,
"l-tile-layer": LTileLayer,
"l-marker": LMarker,
"l-popup": LPopup,
"l-icon": LIcon,
"l-control-attribution": LControlAttribution,
"l-control-zoom": LControlZoom,
"v-marker-cluster": Vue2LeafletMarkerCluster,
},
mounted() {
if (!process.server) //probably not needed but whatever
{
// This makes sure the common error that the images are not found is solved, and also adds the settings to it.
delete Icon.Default.prototype._getIconUrl;
Icon.Default.mergeOptions({
// iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'), // if you want the defaults
// iconUrl: require('leaflet/dist/images/marker-icon.png'), if you want the defaults
// shadowUrl: require('leaflet/dist/images/marker-shadow.png') if you want the defaults
shadowUrl: "/icon_shadow_7.png",
iconUrl: "/housemarkerblue1.png",
shadowAnchor: [10, 45],
iconAnchor: [16, 37],
popupAnchor: [-5, -35],
iconSize: [23, 33],
// staticAnchor: [30,30],
});
}
},
And there's proof using nuxt build --modern=server --analyze
https://i.stack.imgur.com/kc6q4.png
I am replicating my answer here since this is the first post that gets reached searching for this kind of problem, and using the solutions above still caused nuxt to crash or error in my case.
You can import your plugin in your mounted hook, which should run in the client only. So:
async mounted() {
const MyPlugin = await import('some-vue-plugin');
Vue.use(MyPlugin);
}
I do not know about the specific plugin you are trying to use, but in my case I had to call Vue.use() on the default property of the plugin, resulting in Vue.use(MyPlugin.default).

How to use Onsen UI tabbar with Vue single file components

I'm using Vue Onsen UI and trying to render a Vue single file component for each tab.
In the documentation here, they make use of template in a single page. Which is not very reusable. I want to be able to import custom component and render that.
Here is something that I'm trying to do which doesn't seem to work.
<template lang="html">
<v-ons-page>
<!-- top tab bar -->
<v-ons-tabbar position="top" :index="0">
<v-ons-tab label="Browse" page="TaskList">
</v-ons-tab>
<v-ons-tab label="Second">
</v-ons-tab>
</v-ons-tabbar>
</v-ons-page>
</template>
<script>
import TaskList from './TaskList';
export default {
template: '#main',
components: {
'task-list': TaskList,
},
};
</script>
<style lang="scss">
</style>
Can you suggest anything that I should try?
Instead of using tab objects that reference the components directly, use the :tabs property of the tabbar to set up the pages:
<template lang="html">
<v-ons-page>
<v-ons-tabbar position="top" :index="0" :tabs="tabs">
</v-ons-tabbar>
</v-ons-page>
</template>
<script>
import TaskList from './TaskList';
import SecondPage from './SecondPage';
export default {
template: '#main',
data: function () {
return {
tabs: [
{label: 'Browse', page: TaskList},
{label: 'Second', page: SecondPage}
]
}
}
};
</script>
Also, make sure the root element of the components you reference in the page property are <v-ons-page> elements.
I was having the same difficulty with the following syptoms:
Tabs were not appearing at all
No errors in CLI or in console
Note that I was also using the "Hello World" app that is generated from the CLI (vue init OnsenUI/vue-pwa-webpack hello-world)
Resolution
It was pretty simple in the end: there is a file in the root of the folder called vue-onsen-components.js which has all of the components and some of them are commented out. I had to uncomment the following lines and then the tabs appeared:
export { default as VOnsTab } from 'vue-onsenui/esm/components/VOnsTab'
export { default as VOnsTabbar } from 'vue-onsenui/esm/components/VOnsTabbar'