Quasar2 Vue3 Cypress multi-select test fails with strange error - drop-down-menu

My template:
<template>
<q-item tag="label" v-ripple>
<q-select
borderless
stack-label
emit-value
map-options
multiple
class="full-width"
v-model="model"
:options="options"
:label="name"
>
<template v-slot:after>
<q-btn
data-test="btnMultipleDropDownSetting"
round
dense
flat
color="primary"
icon="done"
#click="multiSelectionCompletes"
:disable="submitButtonDisable"
/>
</template>
</q-select>
</q-item>
</template>
Test code:
it('Verify Multiple-Select from auto-generated page', () => {
cy.get('[data-test="multidropdown-setting-4"]').find('label').should('contain', "Auto Generated Multi-Selection box");
cy.get('[data-test="multidropdown-setting-4"]').find('label').click();
cy.get('[data-test="multidropdown-setting-4"]').find('label').select(["Option 1"]);
cy.get('body').click();
cy.get('[data-test="multidropdown-setting-4"]').find('span').should('have.text', 'Option 1');
}); // XXX
Cypress fails with very strange error without even running the test yet:

The Quasar Cypress helpers overrides the .select(), see cypress.overrides.ts
if (subject.hasClass('q-select')) {
if (Array.isArray(valueOrTextOrIndex)) {
if (!subject.hasClass('q-select--multiple')) {
throw new Error(
'Cypress: select command with array param can only be used with a multiple select',
);
}
} else {
valueOrTextOrIndex = [valueOrTextOrIndex];
}
This shows that the
<q-select
...
multiple
>
should throw an error (not the one you get, but the one shown above) when you pass a single value into the .select().
Try passing an array
cy.get('[data-test="multidropdown-setting-4"]')
.find('label')
.select(["Option 1"])
BTW The error message you get above isn't coming from the .select() override, but from the portal helper. I haven't traced back to that point, but try with the above mod first as it seems most likely.
Using cy.withinSelectMenu()
This might also work:
cy.get('[data-test="multidropdown-setting-4"]')
.find('label')
.click(); // open the menu
cy.withinSelectMenu( // should only be one menu open
{persistent: true}, // this means you have a multiple select
() => {
cy.get('.q-item[role=option]').contains("Option 1").click();
})

I was facing the same issue. Got it to work by going through the quasar testing Github repo. Sample Code if anyone is facing similar issue.
cy.dataCy('select').click()
cy.withinSelectMenu({
persistent: true,
fn: () => {
cy.contains('Option One').click()
},
})

Related

Upload multiple files, picking them one by one, with BootstrapVue b-form-file

I'm uploading files with b-form-file in BootstrapVue, setting multiple to true works perfectly for multiple files, BUT each time I choose a file it removes any previously added ones. And the files will often be spread across multiple folders, so I need to be able to pick a file from one folder, choose it, pick another file from another folder, and so on.
Here's the HTML:
<b-form ref="form" novalidate action="upload" method="post" enctype="multipart/form-data">
<b-form-file name="file_upload[]" :multiple="true" :file-name-formatter="formatAssetUpload" no-drop placeholder="Click to choose"></b-form-file>
</b-form>
I've tried adding
ref="fileUpload"
to the b-form-file tag and then in the formatAssetUpload function just setting the value, but that doesn't work. There's a setFiles function in there but it doesn't seem to affect anything. I tried catching the form on submit and manually adding the files to formdata but that's not working either, whatever I try there's always only the last file/files that were picked coming through on the backend.
Any ideas?
Thanks for any help! :)
If you like to remember the file objects which have been selected by the user you an use this javascript:
new Vue({
el: '#app',
data() {
return {
files: [],
filesAccumulated: []
}
},
methods: {
onChange(event) {
this.files.forEach(thisFile => {
this.filesAccumulated.push(thisFile)
})
},
onReset() {
this.filesAccumulated = []
}
}
})
Together whit this vue template
<div id="app">
<b-form-file
v-model="files"
multiple plain
v-on:input="onChange">
</b-form-file>
<div>
<ul>
<li v-for="thisFile in filesAccumulated">
{{ thisFile.name }}
</li>
</ul>
</div>
<b-button v-on:click="onReset">
Reset
</b-button>
</div>
The b-form-file vue component is emitting an input event when the user performs the selection. The file objects can be picked up
from the variable bound with the v-model directive. See documentation
Look at this fiddle https://jsfiddle.net/1xsyb4dq/2/ to see the code in action.
General comments
Let me make some comments on your code example and b-form-file component in general: With bootstrap-vue and <b-form-file> it is not the idea to use an enclosing form tag to submit forms. Instead you use vue's v-model feature to obtain the file objects from vue's data object.
I have created a more comprehensive example here https://jsfiddle.net/to3q7ags/ , but I will explain step by step:
The value of the v-model attribute is the name of a vue data property:
<b-form-file v-model="files" multiple plain></b-form-file>
After the user has clicked 'Browse' in the file input field, has selected the files and pressed ok, the vue data property will hold an array of file objects:
new Vue({
el: '#app',
data() {
return {
files: []
// initially empty, but will be populated after file selection
}
}
})
From the b-form file documentation:
When no files are selected, an empty array will be returned. When a
file or files are selected the return value will be an array of
JavaScript File object instances.
After files are selected by the user you can send the files to your server on a button click or change event.
<b-button v-on:click="onSubmit">
Submit
</b-button>
the corresponding vue method looks like this
methods: {
onSubmit() {
// Process each file
this.files.forEach(thisFile => {
console.log("Submitting " + thisFile.name)
// add submit logic here
}
}
To submit the files you need to manually create a http post request which is using the multipart/form-data encoding. That can be done by using FormData class.
const formBody = new FormData()
formBody.set("formParameter", thisFile)
The FormData.set() method is accepting file blobs as arguments. Then you can use XMLHttpRequest.send() or fetch() to send the post request:
fetch(
"https://yourserver/post",{
method: 'POST',
body: formBody})
.then(response => console.log(response))
Now all selected files are posted to the server. The user can repeat the process with the same form, select new set of files and again press the post button.
You can also send the form automatically without the use of a button by listening to the 'input' event. Drag and drop also works.

Remove Item From Array after ajax response

I'm a frustrated Vue.js noobie coming from jQuery.
I'm trying to do something very basic for a simple project: Delete an article but only after ajax response. While waiting, for the response there's a spinner. (I don't want components or vue files. It's a simple, single file app)
I've been hacking away at it for a few days now and I can't grasp some basic concepts it seems. (Or I want it to behave like jquery)
Fiddle
window.app = new Vue({
el: '#app',
data: {
name: '',
posts: [],
loadingItems: [],
},
created() {
this.fetchData();
},
methods:{
fetchData() {
axios.get('https://jsonplaceholder.typicode.com/posts').then(response => {
this.posts = response.data.slice(0,20);
});
},
removeItem(item) {
var index = this.posts.indexOf(item);
//var loadingIndex = this.loadingItems.push(item) - 1;
//console.log(loadingIndex);
item.isLoading = true;
//Update vue array on the fly hack - https://vuejs.org/2016/02/06/common-gotchas/
this.posts.splice(index, 1,item);
axios.post('//jsfiddle.net/echo/json/', "json='{status:success}'&delay=2")
.then(response => {
this.posts.splice(index, 1);
//this.loadingItems.splice(loadingIndex, 1);
//this.loadingItems.pop(item);
//item.isLoading = false;
//this.posts.splice(index, 1,item);
});
}
},
computed: {
showAlert() {
return this.name.length > 4 ? true : false
}
}
})
<div id="app">
<div v-for="(post,index) in posts" :key="post.id">
<b-card class="mb-2">
<b-card-title>{{post.title}}</b-card-title>
<b-card-text>{{post.body}}</b-card-text>
<a href="#" #click.prevent="removeItem(post)" class="card-link">
Remove
<span v-show="post.isLoading" class="spinner"></span>
</a>
</b-card>
</div>
</div>
Works fine for deleting them 1 by 1 one but not when you click on multiple at the same time, since the index is different by the time the request comes back and it splices the wrong item.
What I've tried so far:
First, it took me a day to figure out that item.isLoading = true; won't work if it wasn't present when the data was first observed (or fetched). However, I don't want to add the property to the database just for a loading animation. So the workaround was to do this.posts.splice(index, 1,item); to "force" Vue to notice my change. Already feels hacky.
Also tried using an array LoadingItems and pushing them while waiting. Didn't work due to the same problem: don't know which one to remove based on index alone.
Studying the TODO app didn't help since it's not quite addressing handling async ajax responses or adding properties at runtime.
Is the best way to do it by using post.id and then trying to pass it and find it back in the array? Really confused and thinking jQuery would have been easier.
Any help will be appreciated. Thanks!
Works fine for deleting them 1 by 1 one but not when you click on multiple at the same time, since the index is different by the time the request comes back and it splices the wrong item.
Don't save the index in a variable. Calculate it every time you need it:
removeItem(item) {
item.isLoading = true;
this.posts.splice(this.posts.indexOf(item), 1, item);
axios.post('/echo/json/', "json='{status:success}'&delay=2")
.then(response => {
this.posts.splice(this.posts.indexOf(item), 1);
});
}

Livewire and CKEditor

I'm trying to use some ritch text editors with livewire. I've used code from here
https://laracasts.com/discuss/channels/laravel/how-to-bind-ckeditor-value-to-laravel-livewire-component
but it gets error in console
CKEditorError: this.viewElement.ownerDocument.body is null
My code:
<div
class="form-textarea w-full"
x-data
x-init="
console.log($refs.myIdentifierHere)
ClassicEditor.create($refs.myIdentifierHere)
.then( function(editor){
editor.model.document.on('change:data', () => {
$dispatch('input', editor.getData())
})
})
.catch( error => {
console.error( error );
} );
"
wire:ignore
wire:key="myIdentifierHere"
x-ref="myIdentifierHere"
wire:model.debounce.9999999ms="description">{!! $description !!}</div>
I have this error too. I've narrowed it down though such that I only have it happen when the ckeditor component is inside of an alpinejs element using x-show to hide it. (I have a form with tabs across the top to switch between the many inputs).
Outside of an alpinejs component, my ckeditor component, which looks identical to yours except for id's, works fine with livewire.

How to show data of a single id using Vue js?

I'm displaying all records on a page at this URI xxx.test/employer/search/filter-by. I'm using Algolia Search to display all of the records/results. I added a button called View Profile and attached an empty method to it called showProfile.
When a user clicks on this button, I want to display this specific profile/record on a new page by itself. If I was fetching data on my own, i.e. without Algolia's code I would be able to do this, but in this case I'm not really sure how to do this.
EmployerSearchComponent.vue:
<ais-instant-search
:search-client="searchClient"
index-name="candidate_profiles"
>
<ais-search-box v-model="query" class="mb-3" placeholder="Search by job title, company name or city..." />
<ais-configure
:restrictSearchableAttributes="['job_title', 'employment_type']"
:hitsPerPage="25"
/>
<b-row class="job-search-card">
<ais-hits class="w-100">
<template slot="item" slot-scope="{ item }">
<b-col cols="12" class="mb-3">
<b-card>
<div class="float-right">
<i class="flaticon-paper-plane"></i> View Profile
</div>
<h4 style="margin-bottom: 20px;"><router-link to="/">{{item.job_title}}</router-link></h4>
<p>Type: {{item.employment_type}}</p>
<b-card-text class="mt-2"><span v-if="item.experience && item.experience.length < 300">{{item.experience}}</span><span v-else>{{item.experience && item.experience.substring(0,300)+"..."}}</span></b-card-text>
</b-card>
</b-col>
</template>
</ais-hits>
<ais-pagination />
</b-row>
</ais-instant-search>
If I click on the network tab in the console, and on the algolia query search URI, I can see that the search results are in results[0].hits. Below is a screenshot of this data.
P.S. My data is empty it just contains algolia client ID's.
How can I display a single id on a new page? I understand that I need to get the id from the record that is being displayed, and show this information on a new page, but I don't know how to put it all together.
Again I think I'll need a route, so I created this
Route::get('/employer/search/filter-by/show/{id}', 'EmployerSearchController#show')->name('employer.search.show');
I'm using Laravel for my backend. Thanks in advance.
------------------------------------- UPDATED: -------------------------------------
I feel like I'm really close, but $itemId in my controller after I die and dump returns "undefined" for some reason.
router.js (Vue Router):
{
path: '/employer/search/filter-by/:itemId/show',
name: 'employer-search-index',
component: SearchIndex,
meta: {
breadcrumb: 'Search Candidates',
requiresAuthEmployer: true,
employerHasPaid: true
},
},
EmployerSearchComponent.vue - with the <router-link> button:
<template slot="item" slot-scope="{ item }">
<b-col cols="12" class="mb-3">
<b-card>
<div class="float-right">
<router-link class="apply-job-btn btn btn-radius theme-btn apply-it" :to="'/employer/search/filter-by/' + item.id + '/show'">View Profile</router-link>
</div>
<h4 style="margin-bottom: 20px;"><router-link to="/">{{item.job_title}}</router-link></h4>
<p>Type: {{item.employment_type}}</p>
<b-card-text class="mt-2"><span v-if="item.experience && item.experience.length < 300">{{item.experience}}</span><span v-else>{{item.experience && item.experience.substring(0,300)+"..."}}</span></b-card-text>
</b-card>
</b-col>
</template>
EmployerSearchController.php:
public function show ($itemId)
{
$candidateProfiles = CandidateProfile::with(['user', 'photo', 'resume', 'video'])
->where('id', '=', $itemId)->get();
return Response::json(array(
'candidateProfiles' => $candidateProfiles,
), 200);
}
api.php:
Route::get('/employer/search/filter-by/{itemId}/show', 'EmployerSearchController#show')->name('employer.search.show');
And Finally, in the .vue file that shows the Full Single Profile, I'm loading the data like this.
mounted() {
this.loadCandidateProfileData();
},
methods: {
loadCandidateProfileData: async function () {
try {
const response = await employerService.loadCandidateProfileData();
this.candidateProfiles = response.data.candidateProfiles;
} catch (error) {
this.$toast.error("Some error occurred, please refresh!");
}
},
}
And the employerService.js file from the above code:
export function loadCandidateProfileData(itemId, data) {
return http().get(`employer/search/filter-by/${itemId}/show`, data);
}
As you suggest, you'll need an API endpoint to fetch the data from, returning it as a JSON object. You'll need to add a route to your client-side routes that takes the job ID (or slug) as a parameter. In your job component, you can retrieve the route param (e.g. in the created() method) as $route.params.id, for example, and use that to fetch the data from your API.
If your Algolia search is returning all the data that you want to display on your single job listing page, you could just put that in your Vuex store and display it without having to make another HTTP request. The downside of that would be that if a user bookmarked the page to return to later, there'd be no data in the store to display.
Thank you to Nilson Jacques responses. If you follow our conversation.
Finally I got the itemId param from the route and passed it to loadCandidateProfileData() like this:
loadCandidateProfileData: async function() {
try {
const itemId = this.$route.params.itemId;
const response = await employerService.loadCandidateProfileData(itemId);
this.candidateProfiles = response.data.candidateProfiles;
console.log(this.candidateProfiles);
if (response.data.candidateProfiles.current_page < response.data.candidateProfiles.last_page) {
this.moreExists = true;
this.nextPage = response.data.candidateProfiles.current_page + 1;
} else {
this.moreExists = false;
}
} catch (error) {
this.$toast.error("Some error occurred, please refresh!");
}
},

AlpineJS - autoselection doesn't work when there is more than one todo

I'm making a little todo app in AlpineJS and I'm running into a problem of autoselecting a field when a user clicks on a todo. This problem only happens when there are multiple todos.
When the user enters the first todo onto the app, they can then click on the todo to edit it. The field will autoselect as expected. However, when the user adds more than one todo onto the app and wants to edit the n+1 todo, the field will no longer autoselects, but the first todo still autoselects just fine.
Here's my CodePen for your reference.
Here's what my edit functionality looks like:
JS
[...]
edit(todo) {
todo.editing = !todo.editing
this.$nextTick(() => {
document.getElementById(`edit-${todo.id}`).select()
})
}
[...]
HTML
[...]
<div
x-text="todo.item"
class="todo-item"
#click="edit(todo)" // <-- point of interest
x-show="!todo.editing"
:class="{ 'completed' : todo.completed }"
>
</div>
<input
type="text"
x-show="todo.editing"
:value="todo.item"
x-model="todo.item"
#keydown.enter="edit(todo)"
#keydown.escape="todo.editing = false"
#click.away="todo.editing = false"
class="edit-input"
:id="`edit-${todo.id}`" // <-- point of interest
/>
[...]
Would anyone have an idea of what might be wrong with my logic? Thank you for the help!
It seems using setTimeout(() => { ... }, 1) (timeout of 1ms) works, see the following Codepen modified from yours.
There are known issues in Alpine.js around $nextTick.
I would also recommend using x-ref instead of ids, so your input would look as follows:
<input
type="text"
x-show="todo.editing"
:value="todo.item"
x-model="todo.item"
#keydown.enter="edit(todo)"
#keydown.escape="todo.editing = false"
#click.away="todo.editing = false"
class="edit-input"
:x-ref="`edit-${todo.id}`"
/>
You can then set edit(todo) to be:
edit(todo) {
todo.editing = !todo.editing
setTimeout(() => {
this.$refs[`edit-${todo.id}`].select()
}, 1)
}

Resources