I have two form fields: country and region. First, you have to choose a country than a region. If a country is selected I add the regions (options) to the next component: Region.
I add the options for the regions with axios.
The scenario where I have an issue: when the user chooses a country then a region then gets back to change country.
The region needs to be reset. I tried to do that with the lifecycle hooks mounted and created but VeeValidate detects no changes. So the value is properly changed but VeeValidate does not detects it. I solved it with adding watch (with nextTick) inside mounted but I doubt it that this is a good solution. Does anyone have any other idea?
I have little experience with Vue.js. Any help is appreciated.
This my full SelectBox.vue:
<template>
<div>
<v-select
#input="setSelected"
:options="choices"
:filterable="filterable"
:value="value"
:placeholder="placeholder"
></v-select>
</div>
</template>
<script>
import vSelect from "vue-select";
import debounce from 'lodash/debounce';
import axios from 'axios';
//axios.defaults.headers.get["Vuejs"] = 1;
export default {
props: [
"options", "filterable",
"value", "url",
"name", "placeholder"
],
data: function () {
var choices = this.options;
if (this.name == "region") {
choices = this.$store.state["regionChoices"] || [];
if (this.value && !choices.includes(this.value)) {
this.toReset = true;
}
if ( Array.isArray(choices) && choices.length < 1) {
this.addError("region", "Country is not selected or there are not available options for your selected country.");
}
}
return {
choices: choices
// refPrefix: this.name + "Ref"
}
},
components:{
vSelect
},
inject: ["addError"],
methods: {
searchRegions: debounce((val, vm) => {
axios.get(vm.url + val)
.then(res => {
vm.$store.state["regionChoices"] = res.data.results;
});
}, 250),
setSelected(val) {
this.$emit("input", val);
if (this.name == "country") {
const countryId = val ? val.value : "0";
this.searchRegions(countryId, this);
}
},
},
mounted() {
if (this.name == "region") {
this.$watch('value', function(value) {
if (this.toReset) {
this.$nextTick(function () {
this.setSelected("");
this.toReset = value ? true : false;
});
}
}, { immediate: true });
}
},
}
</script>
My Vue model was defined in the parent component. So my model property also has to be updated from the parent of my component. I added the method resetRegion to my parent component. And so I can use it in my child component with provide and inject.
My code snippet of the parent component:
export default {
name: "form-template",
components: {
FieldGroup,
},
provide() {
return {
addError: this.addError,
resetRegion: this.resetRegion
}
},
methods: {
...mapMutations({
updateField: "applicant/updateField"
}),
addError(field, msg) {
this.errors.add({field: field, msg: msg});
},
resetRegion() {
this.formData.region = "";
}
}
}
My code snippet of the child component:
export default {
props: [
"options", "value", "multiple",
"name", "placeholder", "url"
],
components:{
vSelect
},
inject: ["addError", "resetRegion"],
computed: {
choices: function () {
let choices = this.options;
if (this.name == "region") {
choices = this.$store.state["regionChoices"];
}
return choices || []
}
},
methods: {
searchRegions(val, vm) {
vm.$store.state["regionChoices"] = [];
axios.get(vm.url + "?pk=" + val)
.then(res => {
const choices = res.data.results || [];
vm.$store.state["regionChoices"] = choices;
if (Array.isArray(choices)) {
const curValue = vm.$store.state.lead.formData.region;
if (choices.length < 1 || (curValue && !choices.some(e => e.id === curValue))) {
this.resetRegion();
}
} else {
this.resetRegion();
}
});
},
setSelected(val) {
this.$emit("input", val);
if (this.name == "country") {
this.searchRegions(val, this);
}
}
},
mounted() {
if (this.name == "region") {
if(!Array.isArray(this.choices) || this.choices.length < 1) {
this.addError("region", window.HNJLib.localeTrans.regionErr);
}
}
},
created() {
if (this.name == "country" && this.value && !this.$store.state.hasOwnProperty("regionChoices")) {
this.searchRegions(this.value, this);
}
}
}
So my problem was solved.
Related
I am trying to build a component that should update the string using setInterval.
Here is the code of the component.
<script>
export default {
data() {
return {
offers: [
'1!',
'2!',
'3!',
],
current_index: 0,
indexInterval: null,
}
},
created() {
this.updateToastIndex()
},
beforeDestroy() {
clearInterval(this.indexInterval)
},
methods: {
updateToastIndex() {
const indexInterval = setInterval(() => {
this.$nextTick(() => {
if (this.current_index === 2) {
this.current_index = 0
console.log(this.offers[this.current_index], 'changedprev')
} else {
this.current_index = this.current_index + 1
console.log(this.offers[this.current_index], 'changed')
}
})
}, 1000)
this.indexInterval = indexInterval
},
},
}
</script>
<template>
<div>{{ offers[current_index] }}</div>
</template>
I can see the current_index in updateToastIndex function getting updated but current_index in template is not getting updated.
Please suggest some solution
i'm completely new to shopware, not very familiar with javascript and struggling with the sw-entity-multi-select field.
I want to save the selected Items to my extension, but i havn't find an easy solution for that, instead i'm doing some awful Object.assign stuff, so i can save at least something.
Question 1: How can i save properly selections to my extension?
Question 2: How can i properly display the saved selections
Here is my html.twig:
{% block sw_product_detail_attribute_sets %}
{% parent() %}
<div v-if="!isLoading && parentProduct && product">
<sw-card :title="$tc('sw-product.detail.requiredProductsCardLabel')"
:isLoading="isLoading">
<sw-inherit-wrapper
v-if="!isLoading"
v-model="RPExtension"
:has-parent="!!parentProduct.id"
:inherited-value="parentRPExtension"
>
<template #content="{ isInherited }">
<sw-entity-multi-select
v-if="!isLoading"
entity="product"
:key="isInherited"
:entity-collection="products"
#change="setProducts"
:placeholder="$tc('sw-product.detail.requiredProductsPlaceholder')">
</sw-entity-multi-select>
</sw-inherit-wrapper>
</sw-card>
</div>
{% endblock %}
And also the index.js for the html.twig:
import template from './sw-product-detail-requiredProducts.html.twig';
const { Component, Context } = Shopware;
const { mapState, mapGetters } = Component.getComponentHelper();
const { EntityCollection, Criteria } = Shopware.Data;
Component.override('sw-product-detail-base', {
template,
inject: ['repositoryFactory'],
data() {
return {
products: null,
};
},
computed: {
...mapState('swProductDetail', [
'product',
'parentProduct',
]),
...mapGetters('swProductDetail', [
'isLoading',
]),
productRepository() {
console.log('productRepository');
return this.repositoryFactory.create('product');
},
requiredProductsRepository() {
return this.repositoryFactory.create('product_preconditions')
},
productContext() {
return { ...Shopware.Context.api, inheritance: true };
},
RPExtension: {
get: function() {
if (this.product && this.product.extensions && this.product.extensions['requiredProducts']) {
return (
this.product.extensions['requiredProducts']['productPrecondition']
);
}
return null;
},
set: function(value) {
this.$set(this.product.extensions['requiredProducts'], 'productPrecondition', value ?? null);
}
},
parentRPExtension() {
if (this.parentProduct && this.parentProduct.extensions && this.parentProduct.extensions['requiredProducts']) {
return (
this.product.extensions['requiredProducts']['productPrecondition']
);
}
return null;
},
},
created() {
this.createdComponent();
},
methods: {
createdComponent() {
this.products = new EntityCollection(
this.productRepository.route,
this.productRepository.entityName,
Shopware.Context.api,
);
},
setProducts(selectedProducts) {
this.products = selectedProducts;
this.products.forEach((selectedProduct) => {
const precondition = this.createProductPreconditionEntity(selectedProduct);
this.product.extensions.requiredProducts.push(precondition);
});
},
createProductPreconditionEntity(selectedProduct) {
const productPreconditions = this.requiredProductsRepository.create(Context.api);
Object.assign(productPreconditions, {
productId: this.product.id,
productPrecondition: selectedProduct.id,
directPrecondition: false
});
return productPreconditions;
},
},
});
In my ticket processing application I currently have a back and forward button contained in my TicketRunner.vue Component, I would like to change it so that these buttons only appear if I have an associated case file, for which I've used V-If:
TicketRunner.Vue
<div class="level nav-btns" v-if='!currentTicketCaseFiles.length'>
<div class="buttons has-addons level-left">
<b-button
#click.prevent="navGoPrev()"
:disabled="currentStepIndex === 0 || navWaiting"
size="is-medium"
>
</div>
export default {
name: 'TicketRunner',
mixins: [NavStepsByIndexMixin()],
components: {
StagePresenter,
CaseFilesStage,
ParticipantsStage,
AttachmentsStage,
CaseFilesRunner,
TicketContextButtons,
},
data: function() {
return {
firstComponentsInitialization: true,
loadingConfirm: false,
confirmationModalActive: false,
confirmationSucceeded: undefined
}
},
props: {
ticketId: {
type: Number,
required: true,
},
},
provide() {
return {
contextButtons: {
capture: (name, callback, title) => this.$refs['contextButtons'].captureButton(name, callback, title),
release: (name) => this.$refs['contextButtons'].releaseButton(name),
enable: (name) => this.$refs['contextButtons'].enableButton(name),
disable: (name) => this.$refs['contextButtons'].disableButton(name),
},
};
},
computed: {
...mapGetters(['currentTicket', 'ticketCaseFiles', 'allCurrentTicketAttachments', 'currentTicketCaseFileNotAssociated',
'currentRequesterType', 'currentTicketStage', 'lastCaseFile']),
caseFiles() {
return this.ticketCaseFiles(this.ticketId);
},
ticketHasAttachments() {
return this.allCurrentTicketAttachments.length > 0;
},
isTicketAssociatedWithCaseFile() {
return !this.currentTicketCaseFileNotAssociated;
},
isFirstNavInitializationInProgress() {
return !this.navReady && this.firstComponentsInitialization;
},
isShowAttachmentsStep() {
return this.ticketHasAttachments && this.currentRequesterType !== 'unknown' &&
(this.isFirstNavInitializationInProgress || this.isTicketAssociatedWithCaseFile)
},
isCurrentTicketResolved() {
return this.currentTicket.status === 'resolved';
},
islastStep() {
return this.navLastStep() && this.lastCaseFile;
}
},
watch: {
ticketId(){
this.navigator.reset();
},
navReady() {
this.moveForwardIfReady();
this.firstComponentsInitialization = false;
}
},
methods: {
...mapActions(['confirmTicket']),
moveForwardIfReady() {
if (this.navigator.currentIndex === 0 && this.firstComponentsInitialization) {
let steps = 0
const step_names = ['case_files_stage']
for(const [_idx, name] of step_names.entries()) {
const ref_name = `step[${name}]`;
if (this.$refs.hasOwnProperty(ref_name) && this.$refs[ref_name].navReady) {
steps += 1
} else {
break
}
}
this.navigator.currentIndex += steps
}
},
confirm() {
this.$buefy.dialog.confirm({
message: this.t('tickets.stages.confirmation.simplified_confirm_reply'),
onConfirm: () => this.confirmStep()
})
},
async confirmStep() {
this.loadingConfirm = true;
const promise = this.confirmTicket(this.ticketId);
return promise.then((response) => {
this.confirmationModalActive = true;
this.confirmationSucceeded = true;
return true; // true is correct here. for goNext it makes parent to stay on on the current step
}).catch(() => {
this.confirmationModalActive = true;
this.confirmationSucceeded = false;
return true; // true is correct here. for goNext it makes parent to stay on on the current step
}).finally(() => this.loadingConfirm = false);
},
},
};
I then receive the following Console Error:
[Vue warn]: Property or method "currentTicketCaseFiles" is not defined on the instance but referenced during render. Make sure that this property is reactive, either in the data option, or for class-based components, by initializing the property.
I know that "!currentTicketCaseFiles.length" works successfully in the Component CaseFilesStage.vue, which makes me believe I should somehow connect the two? But importing it doesn't seem right to me either. I'm not quite sure how to tackle this issue as I'm quite new at VueJS, and would be happy for any pointers. I'll attach the CaseFilesStage.vue Component below.
CaseFilesStage.vue
<template>
<div class="hero">
<div class="block">
<template v-if="!currentTicket.spamTicket">
<b-field>
<b-input
v-model="filter"
:loading="loading"
:placeholder="t('tickets.stages.case_files.search.tooltip')"
v-on:keyup.enter.native="searchCaseFiles"
type="search"
icon="search"
:class="{ 'preview-enabled': showAttachmentsPreview}"
/>
</b-field>
<template v-if="foundCaseFiles.length">
<h4 class="title is-4 table-title">{{ t('tickets.stages.case_files.search.table_title') }}</h4>
<CaseFilesSearchTable
:case-files="foundCaseFilxes"
:found-by-data-points="foundCaseFilesByParticipant"
:show-header="true"
v-slot="cf">
<b-checkbox v-if="cfBelongsToCurrentTicket(cf.id)" :disabled="true" :value="true"></b-checkbox>
<b-checkbox v-else #input="onFoundCaseFile(cf.id, $event)"></b-checkbox>
</CaseFilesSearchTable>
</template>
<div v-else-if="lookupStatus === 'notFound'">
{{ t('tickets.stages.case_files.search.not_found') }}
<!-- display button here if above is activated -->
</div>
</template>
</div>
<template v-if='currentTicketCaseFiles.length'>
<h4 class="title is-4 table-title">{{ t('tickets.stages.case_files.table_title') }}</h4>
<CaseFilesTable :case-files="currentTicketCaseFiles" :show-header="true" v-slot="cf">
<DeleteButton
:model-id="cf.id"
modelName="CaseFile" >
</DeleteButton>
</CaseFilesTable>
</template>
</div>
</template>
<script>
import CaseFilesTable from '../tables/CaseFilesTable';
import CaseFilesSearchTable from '../tables/CaseFilesSearchTable';
import DeleteButton from '../../../../shared/components/controls/DeleteButton';
import { mapGetters, mapActions } from 'vuex';
import { mapServerActions } from "../../../../../../_frontend_infrastructure/javascript/lib/crudvuex_new";
export default {
name: 'CaseFilesStage',
data() {
return {
lookupStatus: 'waitingInput',
filter: '',
waiting: {},
foundCaseFiles: [],
foundCaseFilesByParticipant: {}
};
},
components: {
CaseFilesTable,
CaseFilesSearchTable,
DeleteButton
},
computed: {
...mapGetters(
['currentTicketCaseFiles', 'currentTicketCaseFileNotAssociated', 'currentTicket', 'showAttachmentsPreview']
),
loading() {
return this.lookupStatus === 'waitingServer';
},
allCaseFilesMix(){
this.currentTicketCaseFiles + this.foundCaseFiles
},
foundCaseFilesEx() {
return this.foundCaseFiles.filter((x) => !this.cfBelongsToCurrentTicket(x.id))
},
checkboxValue() {
if(!this.currentTicketCaseFileNotAssociated) {
return null;
}
return true;
},
navReady() {
return this.currentTicket.spamTicket || this.currentTicketCaseFiles.length > 0 || this.checkboxValue;
},
markSpam: {
get: function() {
return this.currentTicket.spamTicket
},
set: function(val) {
return this.updateTicket([this.currentTicket.id, { spam_ticket: val }]);
},
}
},
methods: {
...mapActions(['updateTicket']),
...mapServerActions(['createCaseFile', 'deleteCaseFile']),
cfBelongsToCurrentTicket(id){
return this.currentTicketCaseFiles.map((x) => x.caseFileId).includes(id);
},
cantAssignCaseFileCheckbox(isChecked){
if(isChecked) {
this.createCaseFile({ isCfNotAssociated: true });
} else {
this.deleteCaseFile(this.currentTicketCaseFileNotAssociated);
}
},
onFoundCaseFile(id, useIt){
console.log("onFoundCaseFile: ", id, useIt);
if(useIt) {
this.createCaseFile({ caseFileId: id });
} else {
this.deleteCaseFile(this.currentTicketCaseFiles.find({ caseFileId: id }));
}
},
searchCaseFiles() {
const newData = this.filter;
if (newData.length < 3) { // TODO: some smarter condition here
this.foundCaseFiles = [];
this.lookupStatus = 'waitingInput';
return;
}
this.lookupStatus = 'waitingServer';
this.$axios.get('case_files', { params: { "case_files.filter" : newData } })
.then((response) => {
this.foundCaseFiles = response.data.caseFilesSearchResult.caseFiles;
this.foundCaseFilesByParticipant = response.data.caseFilesSearchResult.foundByPrivateInfo;
if(this.foundCaseFiles.length > 0) {
this.lookupStatus = 'success';
} else {
this.lookupStatus = 'notFound';
}
}).catch(() => this.lookupStatus = 'error');
}
},
};
</script>
</style>
Add this to your TicketRunner.vue Component script:
computed: {
...mapGetters(['currentTicketCaseFiles'])
}
I am trying to implement a rich text editor in Vue.js for a learning purpose. I've been following approach here:
https://roe.dev/blog/building-your-own-vue-rich-text-component
But I'm receiving an error while loading my localhost page.
Is my code missing something? Here are crucial parts:
App.vue ->
<template>
<div><Richinput/></div>
</template>
<script>
import Richinput from './components/Richinput.vue'
export default {
name: 'App',
components: {
Richinput
}
}
</script>
RichInput.vue ->
<template>
<div contenteditable #input="handleInput" #keydown="handleKeydown" />
</template>
<script lang="ts">
const exec = (command: string, value?: string) =>
document.execCommand(command, false, value);
const queryCommandValue = (command: string) =>
document.queryCommandValue(command);
export default {
props: {
value: { type: String, default: "" },
},
mounted() {
this.$el.innerHTML = this.value;
},
watch: {
value(newValue) {
if (this.$el.innerHTML !== newValue) this.$el.innerHTML = newValue;
},
},
methods: {
handleInput(e: InputEvent | KeyboardEvent) {
const { firstChild } = e.target as HTMLElement;
if (firstChild && firstChild.nodeType === 3) exec("formatBlock", "<p>");
else if (this.$el.innerHTML === "<br>") this.$el.innerHTML = "";
this.$emit("input", this.$el.innerHTML);
},
handleDelayedInput(e: KeyboardEvent) {
this.$nextTick(() => this.handleInput(e));
},
},
handleKeydown(e: KeyboardEvent) {
if (
e.key.toLowerCase() === "enter" &&
queryCommandValue("formatBlock") === "blockquote"
) {
this.$nextTick(() => exec("formatBlock", "<p>"));
} else if (e.ctrlKey) {
switch (e.key.toLowerCase()) {
case "b":
e.preventDefault();
this.$nextTick(() => exec("bold"));
break;
case "i":
e.preventDefault();
this.$nextTick(() => exec("italic"));
break;
case "u":
e.preventDefault();
this.$nextTick(() => exec("underline"));
break;
default:
break;
}
}
},
};
</script>
I'm using v-autocomplete from vuetify.js to retrieve a list of values from API Server.
It works fine and my list of values is not empty.
But my problem is when I select the correct value from this list. My script sends another request to server to retrieve another autocomplete list.
Do you have any idea to avoid to send request when a result is selected by the user ? Or to send request only when a key is down ?
My component :
<template>
<div>
<v-autocomplete
v-model="selectValeur"
:loading="loading"
:search-input.sync="search"
:items="resultatsAutocomplete"
class="mb-4"
hide-no-data
hide-details
:label="recherche.label"
></v-autocomplete>
</div>
</template>
<script>
export default {
props: {
recherche: {
type: Object,
default: null,
},
},
data: () => ({
selectValeur: null,
loading: false,
search: null,
resultatsAutocomplete: [],
}),
watch: {
selectValeur(oldval, val) {
console.log(oldval)
console.log(val)
},
search(val) {
val && val !== this.selectValeur && this.fetchEntriesDebounced(val)
console.log(val)
if (!val) {
this.resultatsAutocomplete = []
}
},
},
methods: {
fetchEntriesDebounced(val) {
// cancel pending call
clearTimeout(this._timerId)
// delay new call 500ms
this._timerId = setTimeout(() => {
this.querySelections(val)
}, 500)
},
async querySelections(v) {
if (v.length > 1) {
this.loading = true
try {
const result = await this.$axios.$get(
'myapi/myurl',
{
params: {
racine: v,
},
}
)
this.resultatsAutocomplete = result
console.log(this.resultatsAutocomplete)
this.loading = false
} catch (err) {
console.log(err)
this.loading = false
}
} else {
this.resultatsAutocomplete = []
}
},
},
}
</script>
Thanks,
selectValeur would no longer be null if the user has selected a value, so you could update search() to return if selectValeur is truthy:
export default {
watch: {
search(val) {
if (this.selectValeur) {
// value already selected
return
}
//...
}
}
}
Or you could use vm.$watch on the search property to be able to stop the watcher when selectValeur is set:
export default {
mounted() {
this._unwatchSearch = this.$watch('search', val => {
val && val !== this.selectValeur && this.fetchEntriesDebounced(val)
if (!val) {
this.resultatsAutocomplete = []
}
})
},
watch: {
selectValeur(val) {
if (val && this._unwatchSearch) {
this._unwatchSearch()
}
}
}
}
I found a solution to my problem.
I used the #keyup event to send the axios request and I deleted the watcher on search.
So, the API request are only sent when I press a key.
<template>
<div>
<v-autocomplete
v-model="selectValeur"
:loading="loading"
:items="resultatsAutocomplete"
:search-input.sync="search"
class="mb-4"
hide-no-data
hide-details
:label="recherche.label"
#keyup="keyupSearch"
></v-autocomplete>
</div>
</template>
<script>
export default {
props: {
recherche: {
type: Object,
default: null,
},
},
data: () => ({
selectValeur: null,
loading: false,
resultatsAutocomplete: [],
search: '',
}),
methods: {
keyupSearch(val) {
val &&
val !== this.selectValeur &&
this.fetchEntriesDebounced(this.search)
if (!val) {
this.resultatsAutocomplete = []
}
},
fetchEntriesDebounced(val) {
// cancel pending call
clearTimeout(this._timerId)
// delay new call 500ms
this._timerId = setTimeout(() => {
this.querySelections(val)
}, 500)
},
async querySelections(v) {
if (v.length > 1) {
this.loading = true
try {
const result = await this.$axios.$get(
'my-api/my-url',
{
params: {
sid: this.$route.params.sid,
service: this.$route.params.service,
type: this.recherche.mode,
racine: v,
},
}
)
this.resultatsAutocomplete = result
console.log(this.resultatsAutocomplete)
this.loading = false
} catch (err) {
console.log(err)
this.loading = false
}
} else {
this.resultatsAutocomplete = []
}
},
},
}
</script>