I am attempting to reconfigure a Vue.js Workflowy clone to store list items with Firebase. The clone is based on the following repo: https://github.com/9diov/myflowy. This repo enables the organization of list items into a list tree. List items can be shifted left and right in order to determine their relationship to parent list items. I was successful in implementing an addItem function that adds list items and their levels to Firebase. I am now attempting to implement a method that retrieves items from the database and returns them to the template. So far, I was able to set up a beforeCreate() life cycle hook to retrieve the list item values and levels and push them into the list array. I added console.log(this.list) to confirm that the data is being added to the array. However, the list values and their levels do not render on the screen. How can I beforeCreate() hook to return the value and level stored in Firebase to the tree list?
Here is my component:
<template>
<div id="app">
<span>{{ msg }}</span>
<ul>
<li v-for="(item, index) in list"
v-bind:class="item.level">
<span>•</span>
<input
v-model="item.value"
#keydown.down.prevent="moveDown"
#keydown.up.prevent="moveUp"
#keydown.tab.prevent="shiftRight(index, $event)"
#keydown.shift.tab.prevent="shiftLeft(index, $event)"
#keydown.enter.prevent="addItem(index)"
v-focus="index === focused"
#focus="focused = index"
#blur="focused = null">
</li>
</ul>
</div>
</template>
<script>
import { fblist } from './firebase'
import { focus } from 'vue-focus';
import Vue from 'vue';
const MAX_LEVEL = 10;
export default {
name: 'app',
directives: { focus: focus },
data () {
return {
msg: 'Welcome to Your Vue.js App',
list: [
{ value: 'new item', level: 0 }
],
focused: null
}
},
async created () {
let snapshot = await fblist.get()
const list = []
snapshot.forEach(doc => {
let appData = doc.data()
appData.id = doc.id
// this.list.push(appData)
this.list.push({ value: appData.value, level: appData.level })
console.log(this.list)
})
this.list = list
},
methods: {
moveDown: function() {
this.focused = Math.min(this.focused + 1, this.list.length - 1);
},
moveUp: function () {
this.focused = Math.max(this.focused - 1, 0);
},
shiftLeft: function (index, event) {
let self = this;
self.list[index].level = Math.max(self.list[index].level - 1, 0);
},
shiftRight: function (index, event) {
if (event.shiftKey)
return;
if (index === 0) return;
this.list[index].level = Math.min(this.list[index].level + 1, this.list[index - 1].level + 1, MAX_LEVEL);
},
async addItem (index) {
this.list.splice(index + 1, 0, {value: '', level: this.list[index].level});
this.focused = index + 1;
await fblist.add({
listItem: this.list
})
// this.getData()
}
}
}
</script>
created () hook
async created () {
let snapshot = await fblist.get()
const list = []
snapshot.forEach(doc => {
let appData = doc.data()
appData.id = doc.id
this.list.push({ value: appData.value, level: appData.level })
console.log(list)
})
this.list = list
},
The beforeCreate hook runs at the initialization of your component: data has not been made reactive, and events have not been set up yet. Have a look at https://v2.vuejs.org/v2/api/#beforeCreate and https://v2.vuejs.org/v2/guide/instance.html#Lifecycle-Diagram.
It is quite "standard" to use the created hook for fetching a Firestore database (or any other API). In this hook, you will be able to access reactive data.
So just modify
async beforeCreate () {
//.....
}
to
async created () {
//.....
}
Related
I have a bunch of expansion panels that are rendered from a list. This list has 60+ items in it, so I have set up a pagination to display 5 results at a time. The problem is that the expansion panels do not correctly update with the new lists. I Know that I am returning a new list.
{{this.viewFilteredTools}}
<success-factors v-for="(x,index) in this.viewFilteredTools" :key="index" :factor="x" :tool="tool" />
{{x}}
</v-expansion-panels>
<v-pagination
v-model="page"
:length="this.paginationLength/5"></v-pagination>
This is just holding the expansion panels which are coming in through <success-factors/>.
<script>
import SuccessFactors from './SuccessFactors.vue';
export default {
components: { SuccessFactors },
props:{
tool:{
type: Object,
required: false,
}
},
data() {
return {
page:1,
paginationLength: 0,
factors: {},
factorsList: [],
filterByItems: [
{text:'A to Z (Complete Reviews First)',value:'ascending'},
{text:'Z to A (Complete Reviews First)',value:'descending'},
{text:'A to Z (all)',value:'allAscending'},
{text:'Z to A (all)',value:'allDescending'}
],
filterValue: {text:'Z to A (all)',value:'allDescending'},
viewFilteredTools:[]
};
},
mounted() {
console.log('something happens here')
this.factorsList = this.tool.factors;
this.paginateResults(1,this.factorsList)
this.paginationLength = this.factorsList.length
},
watch: {
page(oldPage){
this.paginateResults(oldPage,this.factorsList);
}
},
// computed:
async fetch() {
const { $content} = this.$nuxt.context;
this.factors = (await $content(this.tool['factors']).fetch());
this.factorsList = this.tool.factors;
},
methods: {
sortList(lstValue) {
console.log(this.tool.factors);
let sortFactors = this.tool.factors;
sortFactors = sortFactors.sort((a,b) => {
if(a<b){
return -1;
}
if(a>b){
return 1;
}
return 0;
})
this.factors = sortFactors;
},
paginateResults(page,results){
console.log(results)
const startCount = (page-1)*5;
const endCount = startCount + 5;
this.startCount = startCount+1;
this.endCount = endCount;
console.log(results.slice(startCount,endCount))
this.viewFilteredTools = results.slice(startCount,endCount);
}
}
};
</script>
this.viewFilteredTools is created from this.factorsList inside the mount lifecycle. Then any time a new page is chosen it is updated from the method sortList(). I can see the viewFilteredTools does change with every page change due to printing it out above the <success-factors>. However, the <success-factors> does not take the data from the updated lists.
Your best bet is to use a computed property. Vue knows when a computed property is updated & would re-render when that property updates.
I am building a React app that includes one separate component for CRUD functionality of Products and another separate component for CRUD functionality of Suppliers.
I am using the same saveData method for both components (the Create functionality of CRUD.. that is triggered when the User presses Save after filling in the input fields of Product or Supplier). The saveData method is located in a central ProductsAndSuppliers.js file that is available to both the Products and Supplier components.
In both of the Product & Supplier components, there is a table showing the Products or Suppliers already present as dummy data.
I made a button at the bottom of each page to add a new Product or Supplier... depending on which tab the user has selected on the left side of the screen (Product or Supplier).
Since I am using the same saveData method in both cases, I have the same problem whenever I try to add a new Product or Supplier to each respective table after filling out the input fields. My new Product or Supplier is added.. but twice and I can't figure out why.
I have tried using a spread operator to add the new item to the collection but am having no success:
saveData = (collection, item) => {
if (item.id === "") {
item.id = this.idCounter++;
this.setState((collection) => {
return { ...collection, item }
})
} else {
this.setState(state => state[collection]
= state[collection].map(stored =>
stored.id === item.id ? item : stored))
}
}
Here is my original saveData method that adds the new Product or Supplier, but twice:
saveData = (collection, item) => {
if (item.id === "") {
item.id = this.idCounter++;
this.setState(state => state[collection]
= state[collection].concat(item));
} else {
this.setState(state => state[collection]
= state[collection].map(stored =>
stored.id === item.id ? item : stored))
}
}
my state looks like this:
this.state = {
products: [
{ id: 1, name: "Kayak", category: "Watersports", price: 275 },
{ id: 2, name: "Lifejacket", category: "Watersports", price: 48.95 },
{ id: 3, name: "Soccer Ball", category: "Soccer", price: 19.50 },
],
suppliers: [
{ id: 1, name: "Surf Dudes", city: "San Jose", products: [1, 2] },
{ id: 2, name: "Field Supplies", city: "New York", products: [3] },
]
}
There are issues with both of your implementations.
Starting with the top one:
// don't do this
this.setState((collection) => {
return { ...collection, item }
})
In this case, collection is your component state and you're adding a property called item to it. You're going to get this as a result:
{
products: [],
suppliers: [],
item: item
}
The correct way to do this with the spread operator is to return an object that represents the state update. You can use a computed property name to target the appropriate collection:
this.setState((state) => ({
[collection]: [...state[collection], item]
})
)
* Note that both this and the example below are using the implicit return feature of arrow functions. Note the parens around the object.
In the second code sample you're
mutating the existing state directly which you should not do.
returning an array instead of a state update object.
// don't do this
this.setState(state =>
state[collection] = state[collection].concat(item)
);
Assignment expressions return the assigned value, so this code returns an array instead of an object and I'd frankly be surprised if this worked at all.
The correct implementation is the same as above except it uses concat instead of spread to create the new array:
this.setState(state => ({
[collection]: state[collection].concat(item)
})
);
needlessly fancy, arguably silly id generators:
const nextId = (function idGen (start = 100) {
let current = start;
return () => current++;
})(100);
console.log(nextId()); // 100
console.log(nextId()); // 101
console.log(nextId()); // 102
// ----------------
// a literal generator, just for fun
const ids = (function* IdGenerator(start = 300) {
let id = start;
while (true) {
yield id++;
}
})();
console.log(ids.next().value); // 300
console.log(ids.next().value); // 301
console.log(ids.next().value); // 302
the expand/collapse part of this works just fine.
Right now I am using javascript startInterval() to reload the table every 2 seconds. Eventually this will be moving to web sockets.
In general, as part of the table load/reload, the system checks to see if it should display the icon " ^ " or " v " in the details column by checking row.detailsShowing, this works fine.
getChevron(row, index) {
if (row.detailsShowing == true) {
return "chevronDown";
}
return "chevronUp";
}
When the user selects the " ^ " icon in the relationship column, #click=row.toggleDetails gets called to expand the row and then the function v-on:click="toggleRow(row)" is called to keep track of which row the user selected. This uses a server side system generated guid to track.
Within 2 seconds the table will reload and the row collapses. On load/reload, in the first column it loads, relationship, I call a function checkChild(row), to check the row guid against my locally stored array, to determine if this is a row that should be expanded on load.
<template #cell(relationship)="row"> {{checkChild(row)}} <\template>
if the row guid matches one in the array I try setting
checkChild(row){
var idx = this.showRows.indexOf( row.item.id);
if(idx > -1){
row.item.detailsShowing = true;
row.rowSelected = true;
row.detailsShowing == true
row._showDetails = true;
}
}
and I am able to see that i have found match, but none of those variables set to true keeps the expanded row open, the row always collapses on reload
anyone have any ideas as to how i can make the row(s) stay open on table reload?
The issue with your code is because of a Vue 2 caveat. Adding properties to objects after they've been added to data will not be reactive. To get around this you have to utilize Vue.set.
You can read more about that here.
However, calling a function like you are doing in the template seems like bad practice.
You should instead do it after fetching your data, or use something like a computed property to do your mapping.
Here's two simplified examples.
Mapping after API call
{
data() {
return {
items: [],
showRows: []
}
},
methods: {
async fetchData() {
const { data } = await axios.get('https://example.api')
foreach(item of data) {
const isRowExpanded = this.showRows.includes(item.id);
item._showDetails = isRowExpanded;
}
this.items = data;
}
}
}
Using a computed
{
computed: {
// Use `computedItems` in `<b-table :items="computedItems">`
computedItems() {
const { items, showRows } = this;
return items.map(item => ({
...item,
_showDetails: .showRows.includes(item.id)
}))
}
},
data() {
return {
items: [],
showRows: []
}
},
methods: {
async fetchData() {
const { data } = await axios.get('https://example.api')
this.items = data;
}
}
}
For a more complete example, check the snippet below.
const {
name,
datatype,
image
} = faker;
const getUser = () => ({
uuid: datatype.uuid(),
personal_info: {
first_name: name.firstName(),
last_name: name.lastName(),
gender: name.gender(),
age: Math.ceil(Math.random() * 75) + 15
},
avatar: image.avatar()
});
const users = new Array(10).fill().map(getUser);
new Vue({
el: "#app",
computed: {
computed_users() {
const {
expanded_rows,
users
} = this;
return users.map((user) => ({
...user,
_showDetails: expanded_rows[user.uuid]
}));
},
total_rows() {
const {
computed_users
} = this;
return computed_users.length;
}
},
created() {
this.users = users;
setInterval(() => {
users.push(getUser());
this.users = [...users];
}, 5000);
},
data() {
return {
per_page: 5,
current_page: 1,
users: [],
fields: [{
key: "avatar",
class: "text-center"
},
{
key: "name",
thClass: "text-center"
},
{
key: "personal_info.gender",
label: "Gender",
thClass: "text-center"
},
{
key: "personal_info.age",
label: "Age",
class: "text-center"
}
],
expanded_rows: {}
};
},
methods: {
onRowClicked(item) {
const {
expanded_rows
} = this;
const {
uuid
} = item;
this.$set(expanded_rows, uuid, !expanded_rows[uuid]);
}
}
});
<link href="https://unpkg.com/bootstrap#4.5.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://unpkg.com/bootstrap-vue#2.21.2/dist/bootstrap-vue.css" rel="stylesheet" />
<script src="https://unpkg.com/vue#2.6.12/dist/vue.min.js"></script>
<script src="https://unpkg.com/bootstrap-vue#2.21.2/dist/bootstrap-vue.js"></script>
<script src="https://unpkg.com/faker#5.5.3/dist/faker.min.js"></script>
<div id="app" class="p-3">
<b-pagination v-model="current_page" :per-page="per_page" :total-rows="total_rows">
</b-pagination>
<h4>Table is refresh with a new item every 5 seconds.</h4>
<h6>Click on a row to expand the row</h6>
<b-table :items="computed_users" :fields="fields" bordered hover striped :current-page="current_page" :per-page="per_page" #row-clicked="onRowClicked">
<template #cell(avatar)="{ value }">
<b-avatar :src="value"></b-avatar>
</template>
<template #cell(name)="{ item: { personal_info: { first_name, last_name } }}">
{{ first_name }} {{ last_name }}
</template>
<template #row-details="{ item }">
<pre>{{ item }}</pre>
</template>
</b-table>
</div>
I've just recently started to dive in into Vue JS - loved it so far.
I'm facing an issue now where I'm trying to create a (non-trivial) table (using vue-good-table plugin) in which each cell is a component by it's own.
Having read the documentation of the plugin, it's being mentioned that it's possible to create an HTML column types where you can just use, well, a raw HTML (I guess):
https://xaksis.github.io/vue-good-table/guide/configuration/column-options.html#html
To simplify things, here's what I have - a Vue component (called Dashboard2.vue) that holds the table and the child component called Test.vue
I'm creating the Test components dynamically per each relevant cell and assigning it to the relevant row cell.
since I've defined the columns to be HTML types, I'm using the innerHTML property to extract the raw HTML out of the Vue component. (following this article https://css-tricks.com/creating-vue-js-component-instances-programmatically/)
It all goes very well and the dashboard looks exactly how I wanted it to be, but when clicking the button inside each Test component, nothing happens.
I suspect that since I've used the innerHTML property it just skips Vue even handler mechanism somehow, so I'm kinda stuck.
Here's the relevant components section:
Dashboard2.vue:
<template>
<div>
<vue-good-table
:columns="columns"
:rows="rows"
:search-options="{enabled: true}"
styleClass="vgt-table condensed bordered"
max-height="700px"
:fixed-header="true"
theme="black-rhino">
</vue-good-table>
</div>
</template>
<script>
import axios from 'axios';
import Vue from 'vue';
import { serverURL } from './Config.vue';
import Test from './Test.vue';
export default {
name: 'Dashboard2',
data() {
return {
jobName: 'team_regression_suite_for_mgmt',
lastXBuilds: 7,
builds: [],
columns: [
{
label: 'Test Name',
field: 'testName',
},
],
rows: [],
};
},
methods: {
fetchResults() {
const path = `${serverURL}/builds?name=${this.jobName}&last_x_builds=${this.lastXBuilds}`;
axios.get(path)
.then((res) => {
this.builds = res.data;
this.builds.forEach(this.createColumnByBuildName);
this.createTestsColumn();
this.fillTable();
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
});
},
createBaseRow(build) {
return {
id: build.id,
name: build.name,
cluster: build.resource_name,
startTime: build.timestamp,
runtime: build.duration_min,
estimatedRuntime: build.estimated_duration_min,
result: build.result,
};
},
addChildRows(build, children) {
const row = this.createBaseRow(build);
// eslint-disable-next-line no-plusplus
for (let i = 0; i < build.sub_builds.length; i++) {
const currentBuild = build.sub_builds[i];
if (currentBuild.name === '') {
this.addChildRows(currentBuild, children);
} else {
children.push(this.addChildRows(currentBuild, children));
}
}
return row;
},
createColumnByBuildName(build) {
this.columns.push({ label: build.name, field: build.id, html: true });
},
addRow(build) {
const row = this.createBaseRow(build);
row.children = [];
this.addChildRows(build, row.children);
this.rows.push(row);
},
createTestsColumn() {
const build = this.builds[0];
const row = this.createBaseRow(build);
row.children = [];
this.addChildRows(build, row.children);
// eslint-disable-next-line no-plusplus
for (let i = 0; i < row.children.length; i++) {
this.rows.push({ testName: row.children[i].name });
}
},
fillBuildColumn(build) {
const row = this.createBaseRow(build);
row.children = [];
this.addChildRows(build, row.children);
// eslint-disable-next-line no-plusplus
for (let i = 0; i < row.children.length; i++) {
const childBuild = row.children[i];
const TestSlot = Vue.extend(Test);
const instance = new TestSlot({
propsData: {
testName: childBuild.name,
result: childBuild.result,
runTime: childBuild.runtime.toString(),
startTime: childBuild.startTime,
estimatedRunTime: childBuild.estimatedRuntime.toString(),
},
});
instance.$mount();
this.rows[i] = Object.assign(this.rows[i], { [build.id]: instance.$el.innerHTML });
}
},
fillTable() {
this.builds.forEach(this.fillBuildColumn);
},
},
created() {
this.fetchResults();
},
};
</script>
<style scoped>
</style>
Test.vue
<template>
<div>
<b-card :header="result" class="mb-2" :bg-variant="variant"
text-variant="white">
<b-card-text>Started: {{ startTime }}<br>
Runtime: {{ runTime }} min<br>
Estimated: {{ estimatedRunTime }} min
</b-card-text>
<b-button #click="sayHi" variant="primary">Hi</b-button>
</b-card>
</div>
</template>
<script>
export default {
name: 'Test',
props: {
id: String,
testName: String,
build: String,
cluster: String,
startTime: String,
runTime: String,
estimatedRunTime: String,
result: String,
},
computed: {
variant() {
if (this.result === 'SUCCESS') { return 'success'; }
if (this.result === 'FAILURE') { return 'danger'; }
if (this.result === 'ABORTED') { return 'warning'; }
if (this.result === 'RUNNING') { return 'info'; }
return 'info';
},
},
methods: {
sayHi() {
alert('hi');
},
},
};
</script>
<style scoped>
</style>
I know this is a lot of code.
The specific relevant section (in Dashboard2.vue) is fillBuildColumn
Again - I'm a newbie to Vue JS - that being said my hunch tells me I'm
doing many things wrong here.
Any help will be greatly appreciated.
EDIT:
by loosing the innerHTML property and the html type I'm ending up with a:
"RangeError: Maximum call stack size exceeded" thrown by the browser.
Not sure what's causing it
I have made a CodeSandbox sample. I might have messed with the data part. But it gives the idea.
fillBuildColumn(build) {
const row = this.createBaseRow(build);
row.children = [];
this.addChildRows(build, row.children);
// eslint-disable-next-line no-plusplus
for (let i = 0; i < row.children.length; i++) {
const childBuild = row.children[i];
// i might have messed up with the data here
const propsData = {
testName: childBuild.name,
result: childBuild.result,
runTime: childBuild.runtime.toString(),
startTime: childBuild.startTime,
estimatedRunTime: childBuild.estimatedRuntime.toString()
};
this.rows[i] = Object.assign(this.rows[i], {
...propsData
});
}
}
createColumnByBuildName(build) {
this.columns.push({
label: build.name,
field: "build" + build.id //guessable column name
});
}
<vue-good-table :columns="columns" :rows="rows">
<template slot="table-row" slot-scope="props">
<span v-if="props.column.field.startsWith('build')">
<Cell
:testName="props.row.testName"
:build="props.row.build"
:cluster="props.row.cluster"
:startTime="props.row.startTime"
:runTime="props.row.runTime"
:estimatedRunTime="props.row.estimatedRunTime"
:result="props.row.result"
></Cell>
</span>
<span v-else>{{props.formattedRow[props.column.field]}}</span>
</template>
</vue-good-table>
The idea is rendering a component inside a template and do it conditionally. The reason giving guessable column name is to use condition like <span v-if="props.column.field.startsWith('build')">. Since you have only 1 static field the rest is dynamic you can also use props.column.field !== 'testName'. I had problems with rendering i had to register table plugin and Cell component globally.
I'm currently practicing Vue with Vuex and the REST API architecture (with Django). I wanna retrieve the data from by database and store the data in my global state in order to access it throughout my Vue components. It works fine so far but there is one weird thing I do not understand.
When I start my application I have the homepage where currently nothing is displayed. I click on the menu item "Contacts" and the contacts component loads (router-view) (API GET call is executed) and displays the table of all the created contacts and shows it properly (source of truth is now my global state). I can edit, delete and view a contact.The contacts are now stored in the global state as well.
The problem: Everytime I load the contact component the mounted() lifecycle gets called (which makes sense) and loads the contacts from the API inside the state so the whole lists gets dupblicated over and over. I just want Vue to make a GET request only once and then access the state's data (where to contacts are stored now).
Another scenario is when I update a contact and click back to the contacts menu item the list contains the old contact and the updated one but when I refresh the page it is fine.
Thanks!
MY CODE
state.js
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
import { apiService } from "../utils/api.service.js";
export const store = new Vuex.Store({
state: {
contacts: []
},
mutations: {
initContacts_MU: (state, data) => {
state.contacts.push(...data);
},
updateContact_MU: (state, data) => {
let getContact = state.contacts.filter(contact => contact.id === data.id);
let getContactIndex = state.contacts.indexOf(getContact);
state.contacts.splice(getContactIndex, 1, data);
},
deleteContact_MU: (state, data) => {
let getContact = state.contacts.filter(contact => contact.id === data.id);
let getContactIndex = state.contacts.indexOf(getContact);
state.contacts.splice(getContactIndex, 1);
},
createContact_MU: (state, data) => {
state.contacts.unshift(data);
}
},
actions: {
initContacts_AC({ commit }) {
let endpoint = "api/contacts/";
apiService(endpoint).then(data => {
commit("initContacts_MU", data);
});
},
updateContact_AC({ commit }, contact) {
let endpoint = "/api/contacts/" + contact.slug + "/";
apiService(endpoint, "PUT", contact).then(contact => {
commit("updateContact_MU", contact);
});
},
deleteContact_AC({ commit }, contact) {
let endpoint = "/api/contacts/" + contact.slug + "/";
apiService(endpoint, "DELETE", contact).then(contact => {
commit("deleteContact_MU", contact);
});
},
createContact_AC({ commit }, contact) {
let endpoint = "/api/contacts/";
apiService(endpoint, "POST", contact).then(contact => {
commit("createContact_MU", contact);
});
}
},
getters: {
contacts: state => {
return state.contacts;
}
}
});
ContactList.vue
<script>
import Contact from "../../components/Contacts/Contact.vue";
import { mapGetters, mapActions } from "vuex";
export default {
name: "ContactList",
components: {
Contact
},
computed: {
...mapGetters(["contacts"])
},
methods: {
...mapActions(["initContacts_AC"])
},
mounted() {
this.initContacts_AC();
}
};
</script>
Just check if there're contacts which are already retrieved from backend.
computed: {
...mapGetters(["contacts"])
},
methods: {
...mapActions(["initContacts_AC"])
},
mounted() {
if (this.contacts && this.contacts.length > 0) return; // already fetched.
this.initContacts_AC();
}
EDIT:
updateContact_MU: (state, data) => {
const contactIndex = state.contacts.findIndex(contact => contact.id === data.id);
if (contactIndex < 0) return;
state.contacts.splice(contactIndex, 1, data);
}