Commentaires
J'ai décidé de dédier une partie complète à ce cette problématique parfois mal connue par les étudiants qui apprennent à utiliser Vue. J'ai trop souvent vu de tutoriels où cette notion n'était pas abordée (et parfois même des corrections par des développeurs web qui sont fausses !).

Alors avant d'attaquer la solution à la problématique, essayons d'abord de la comprendre.

Arthur, l'apprenti développeurOui, quelle est cette problématique ?
Rappelle-toi ce que l'on désire faire dans cette partie : permettre aux utilisateurs de modifier le titre d'une photo. Les photos sont récupérées depuis l'API dans le composant App et sont passées en "props" au composant "Photo". Naïvement, on aurait donc fait la chose suivante pour modifier leur titre :

{"language":"text/html","content":"<template>\n\t<!-- .. -->\n\t<input :value=\"title\" @update=\"title = $event.target.value\">\n</template>","filename":"Photo.vue"}

Ou en version raccourcie avec v-model :

{"language":"text/html","content":"<template>\n\t<!-- .. -->\n\t<input v-model=\"title\">\n</template>","filename":"Photo.vue"}

Le problème apparait alors avec ESLint :
9:36 error Unexpected mutation of "title" prop vue/no-mutating-props

Arthur, l'apprenti développeurQu'est-ce que cela signifie ?
Cela signifie qu'on a une erreur car on essaie de modifier la valeur d'une props, ce que Vue ne souhaite pas. En effet, la logique de Vue consiste à dire qu'un enfant ne peut pas modifier les données de son parent de cette manière, ça pourrait amener à des problématiques de "consistance" de l'application. C'est ce qu'on appelle un problème de "State management" (et on a des solutions pour palier à ce problème dans des grosses applications grâce à Vuex notamment, on verra ça bien plus tard).

Arthur, l'apprenti développeurOk je vois l'idée. Mais du coup, comment peut-on faire ?
Hé bien on va utiliser v-model mais de manière un peu différente ;). Premièrement, il faut appliquer v-model sur le composant enfant directement dans le composant parent.

Arthur, l'apprenti développeurHein ?
C'est vrai que c'était peu clair. Un exemple te parlera sans doute mieux !
{"language":"text/html","content":"<template>\n <modal v-show=\"open\" @closeModal=\"close\">\n <template #title>Plus d'info</template>\n <template #default>\n Quelques explications à propos du site...\n </template>\n </modal>\n <h1 id=\"welcome\">Bienvenue sur notre site d'avis de photos !</h1>\n <button @click=\"open = !open\" class=\"button centered\">{{ openCloseText }}</button>\n\n <slider :slides=\"photos\" id=\"slider\">\n <template #default=\"{slide : photo, key : index}\">\n <h2>Photo {{ index+1 }}/{{ photos.length }}</h2>\n <photo \n v-bind=\"photo\"\n\t\t\t\tv-model=\"photo.title\"\n ></photo>\n </template>\n </slider>\n</template>\n\n<script>\nimport Photo from './components/Photo.vue'\nimport Slider from './components/Slider.vue'\nimport Modal from './components/Modal.vue'\n\nconst API_URL = 'https://api.nasa.gov/planetary/apod?start_date=2021-02-01&end_date=2021-02-21&api_key=DEMO_KEY'\nexport default {\n name: 'App',\n components: {\n Photo, Slider, Modal\n },\n data() {\n return {\n photos: [],\n open:false,\n }\n },\n created() {\n fetch(API_URL)\n .then(result => result.json())\n .then(result => {this.photos = result})\n },\n computed: {\n openCloseText() {\n return open ? 'En savoir +' : 'Fermer la modal'\n }\n },\n methods: {\n close(message) {\n this.open = false\n console.log(message)\n }\n }\n}\n</script>\n\n<style>\n #app{\n font-family: 'Roboto', sans-serif;\n }\n\n #welcome {\n text-align:center;\n }\n\n .button{\n display:block;\n border:none;\n padding:10px 15px;\n font-size:1.2em;\n color:white;\n background: #818CF8;\n cursor:pointer;\n }\n\n .centered{\n margin:10px auto;\n }\n\n #slider{\n width:80%;\n margin:auto;\n }\n</style>\n","filename":"App.vue"}


Mais il y a un léger problème avec ce code. Pour le déceler, il faut comprendre ce que fait v-model lorsqu'on l'utilise sur un composant.
Au début de ce chapitre je t'ai rappelé la chose suivante :

{"language":"text/html","content":"<input v-model=\"champ\">\n<!-- est équivalent à -->\n<input :value=\"champ\" @input=\"champ = $event.target.value\">","filename":""}

Mais avec les composants, ce n'est pas ce qui se fait. Non, on a avec les composants l'équivalence suivante :

{"language":"text/html","content":"<mon-composant v-model=\"champ\" />\n<!-- est équivalent à -->\n<mon-composant :model-value=\"champ\" @update:model-value=\"champ = $event\" />","filename":""}

Arthur, l'apprenti développeurOula ! Donc en fait si je comprends bien, on ne passe pas la valeur "champ" dans une props qui a pour nom "champ" mais une props qui a pour nom "model-value" ? C'est toujours ce nom là ?
Tu as tout compris. Et oui, il aura toujours ce nom là. Maintenant qu'on a mis au clair ce que faisait v-model sur un composant, il est donc tant de modifier notre code car actuellement nous avons un "problème". En effet, nous faisons v-bind="photo" et v-model="photo.title" sur le même élément. Au final, on aura donc deux fois la valeur title qui sera passée, sous deux noms de props différents. Mais si on veut tirer profit de notre v-model, il faut modifier notre composant Photo en rajoutant la props pour "model-value" !

{"language":"text/html","content":"<template>\n <figure class=\"photo\" v-bind=\"$attrs\">\n <img :src=\"url\" :alt=\"title + 'prise par' +copyright\" />\n <figcaption>\n <h2>{{ title }} prise le {{ formattedDate }} par {{ copyright }}</h2>\n <p>{{ explanation }}</p>\n </figcaption>\n </figure>\n <button @click=\"currentTab = 'Views'\" class=\"button tab\" :class=\"{underline:(currentTab == 'Views')}\">Avis</button>\n <button @click=\"currentTab = 'ViewForm'\" class=\"button tab\" :class=\"{underline:(currentTab == 'ViewForm')}\">Rédiger un avis</button>\n <keep-alive>\n <component :is=\"currentTab\" v-bind=\"dynamicProps\" @add-view=\"storeView\"></component>\n </keep-alive>\n</template>\n\n<script>\nimport Views from './Views.vue'\nimport ViewForm from './ViewForm.vue'\n\nexport default {\n components:{\n Views, ViewForm\n },\n props:{\n url: {\n type:String, \n required:true,\n default:'http://uneimagepardefaut.png'\n },\n modelValue: { // Je rajoute la props \"model-value\". Attention kebab-case / CamelCase\n type:String,\n required:true,\n },\n title: {\n type:String,\n required:true,\n },\n date: {\n type:String,\n required:true,\n default:Date.now()\n },\n explanation: {\n type:String,\n required:true,\n },\n copyright: {\n type:String,\n required:false,\n default:'un inconnu'\n }\n },\n data() {\n return {\n currentTab:'Views',\n views: [ ]\n }\n },\n computed: {\n formattedDate() {\n let date = new Date(this.date)\n let day = Number(date.getDate()) >= 10 ? date.getDate() : '0'+date.getDate()\n return `${day}/${date.getMonth()}/${date.getFullYear()}`\n },\n dynamicProps() {\n if(this.currentTab == 'Views') {\n return { views:this.views }\n }\n return null\n }\n }, \n methods:{\n storeView(author, content) {\n this.views.push({author:author, content:content})\n }\n }\n}\n</script>\n\n<style scoped>\n .underline{\n text-decoration: underline\n }\n .tab{\n display:inline-block;\n border-radius: 20px 20px 0 0;\n }\n\n .photo img{\n max-height:300px;\n }\n\n .photo{\n text-align:center;\n }\n</style>","filename":"Photo.vue"}


Ensuite, on peut mettre notre input pour modifier le champ title en utilisant la props "model-value". Attention cependant, on ne pourra pas utiliser v-model dessus. Nous ce qu'on veut c'est envoyer la donnée au parent. D'ailleurs, le parent écoute un évènement nommé "update:model-value" ! Il faut donc lui envoyer grâce à un emits !

{"language":"text/html","content":"<template>\n <figure class=\"photo\" v-bind=\"$attrs\">\n <img :src=\"url\" :alt=\"title + 'prise par' +copyright\" />\n <figcaption>\n <h2>{{ title }} prise le {{ formattedDate }} par {{ copyright }}</h2>\n <p>{{ explanation }}</p>\n </figcaption>\n <input\n :value=\"modelValue\"\n @input=\"$emit('update:modelValue', $event.target.value)\"\n >\n </figure>\n <button @click=\"currentTab = 'Views'\" class=\"button tab\" :class=\"{underline:(currentTab == 'Views')}\">Avis</button>\n <button @click=\"currentTab = 'ViewForm'\" class=\"button tab\" :class=\"{underline:(currentTab == 'ViewForm')}\">Rédiger un avis</button>\n <keep-alive>\n <component :is=\"currentTab\" v-bind=\"dynamicProps\" @add-view=\"storeView\"></component>\n </keep-alive>\n</template>\n\n<script>\nimport Views from './Views.vue'\nimport ViewForm from './ViewForm.vue'\n\nexport default {\n components:{\n Views, ViewForm\n },\n emits:['update:modelValue'],\n props:{\n url: {\n type:String, \n required:true,\n default:'http://uneimagepardefaut.png'\n },\n modelValue: {\n type:String,\n required:true,\n },\n title: {\n type:String,\n required:true,\n },\n date: {\n type:String,\n required:true,\n default:Date.now()\n },\n explanation: {\n type:String,\n required:true,\n },\n copyright: {\n type:String,\n required:false,\n default:'un inconnu'\n }\n },\n data() {\n return {\n currentTab:'Views',\n views: [ ]\n }\n },\n computed: {\n formattedDate() {\n let date = new Date(this.date)\n let day = Number(date.getDate()) >= 10 ? date.getDate() : '0'+date.getDate()\n return `${day}/${date.getMonth()}/${date.getFullYear()}`\n },\n dynamicProps() {\n if(this.currentTab == 'Views') {\n return { views:this.views }\n }\n return null\n }\n }, \n methods:{\n storeView(author, content) {\n this.views.push({author:author, content:content})\n }\n }\n}\n</script>\n\n<style scoped>\n .underline{\n text-decoration: underline\n }\n .tab{\n display:inline-block;\n border-radius: 20px 20px 0 0;\n }\n\n .photo img{\n max-height:300px;\n }\n\n .photo{\n text-align:center;\n }\n</style>","filename":"Photo.vue"}

Et voilà, le problème est réglé ! On a un v-model qui marche, qui répercute tout correctement !

Arthur, l'apprenti développeurOk, je comprends mieux ! Mais il y a un autre problème qui m'apparait en faisant comme ça...
Lequel ?

Arthur, l'apprenti développeurSi on voulait modifier ET notre titre ET notre description par exemple, comment ferait-on ? Car on ne vas pas utiliser deux fois la props "model-value" !
Tu as tout à fait raison. En fait, on peut nommer les v-model... En utilisant v-model:nom="valeur". Du coup on va modifier notre composant App.vue :

{"language":"text/html","content":"<template>\n <modal v-show=\"open\" @closeModal=\"close\">\n <template #title>Plus d'info</template>\n <template #default>\n Quelques explications à propos du site...\n </template>\n </modal>\n <h1 id=\"welcome\">Bienvenue sur notre site d'avis de photos !</h1>\n <button @click=\"open = !open\" class=\"button centered\">{{ openCloseText }}</button>\n\n <slider :slides=\"photos\" id=\"slider\">\n <template #default=\"{slide : photo, key : index}\">\n <h2>Photo {{ index+1 }}/{{ photos.length }}</h2>\n <photo \n :url=\"photo.url\" \n :copyright=\"photo.copyright\"\n :date=\"photo.date\"\n v-model:title=\"photo.title\"\n v-model:explanation=\"photo.explanation\"\n\n ></photo>\n </template>\n </slider>\n</template>\n\n<script>\nimport Photo from './components/Photo.vue'\nimport Slider from './components/Slider.vue'\nimport Modal from './components/Modal.vue'\n\nconst API_URL = 'https://api.nasa.gov/planetary/apod?start_date=2021-02-01&end_date=2021-02-21&api_key=DEMO_KEY'\nexport default {\n name: 'App',\n components: {\n Photo, Slider, Modal\n },\n data() {\n return {\n photos: [],\n open:false,\n }\n },\n created() {\n fetch(API_URL)\n .then(result => result.json())\n .then(result => {this.photos = result})\n },\n computed: {\n openCloseText() {\n return open ? 'En savoir +' : 'Fermer la modal'\n }\n },\n methods: {\n close(message) {\n this.open = false\n console.log(message)\n }\n }\n}\n</script>\n\n<style>\n #app{\n font-family: 'Roboto', sans-serif;\n }\n\n #welcome {\n text-align:center;\n }\n\n .button{\n display:block;\n border:none;\n padding:10px 15px;\n font-size:1.2em;\n color:white;\n background: #818CF8;\n cursor:pointer;\n }\n\n .centered{\n margin:10px auto;\n }\n\n #slider{\n width:80%;\n margin:auto;\n }\n</style>\n","filename":"App.vue"}

Et modifier notre composant Photo.vue :

{"language":"text/html","content":"<template>\n <figure class=\"photo\" v-bind=\"$attrs\">\n <img :src=\"url\" :alt=\"title + 'prise par' +copyright\" />\n <figcaption>\n <h2>{{ title }} prise le {{ formattedDate }} par {{ copyright }}</h2>\n <p>{{ explanation }}</p>\n </figcaption>\n <div>\n <label for=\"title\">Modifier le titre : </label>\n <input\n id=\"title\"\n :value=\"title\"\n @input=\"$emit('update:title', $event.target.value)\"\n >\n </div>\n <div>\n <label for=\"explanation\">Modifier l'explication : </label>\n <textarea \n id=\"explanation\"\n :value=\"explanation\"\n @input=\"$emit('update:explanation', $event.target.value)\"\n ></textarea>\n </div>\n </figure>\n <button @click=\"currentTab = 'Views'\" class=\"button tab\" :class=\"{underline:(currentTab == 'Views')}\">Avis</button>\n <button @click=\"currentTab = 'ViewForm'\" class=\"button tab\" :class=\"{underline:(currentTab == 'ViewForm')}\">Rédiger un avis</button>\n <keep-alive>\n <component :is=\"currentTab\" v-bind=\"dynamicProps\" @add-view=\"storeView\"></component>\n </keep-alive>\n</template>\n\n<script>\nimport Views from './Views.vue'\nimport ViewForm from './ViewForm.vue'\n\nexport default {\n components:{\n Views, ViewForm\n },\n emits:['update:title', 'update:explanation'],\n props:{\n url: {\n type:String, \n required:true,\n default:'http://uneimagepardefaut.png'\n },\n title: {\n type:String,\n required:true,\n },\n date: {\n type:String,\n required:true,\n default:Date.now()\n },\n explanation: {\n type:String,\n required:true,\n },\n copyright: {\n type:String,\n required:false,\n default:'un inconnu'\n }\n },\n data() {\n return {\n currentTab:'Views',\n views: [ ]\n }\n },\n computed: {\n formattedDate() {\n let date = new Date(this.date)\n let day = Number(date.getDate()) >= 10 ? date.getDate() : '0'+date.getDate()\n return `${day}/${date.getMonth()}/${date.getFullYear()}`\n },\n dynamicProps() {\n if(this.currentTab == 'Views') {\n return { views:this.views }\n }\n return null\n }\n }, \n methods:{\n storeView(author, content) {\n this.views.push({author:author, content:content})\n }\n }\n}\n</script>\n\n<style scoped>\n .underline{\n text-decoration: underline\n }\n .tab{\n display:inline-block;\n border-radius: 20px 20px 0 0;\n }\n\n .photo img{\n max-height:300px;\n }\n\n .photo{\n text-align:center;\n }\n</style>","filename":"Photo.vue"}

Et voilà ! Tu sais utiliser v-model sur des props !

Arthur, l'apprenti développeurCool ! J'ai appris déjà plein de choses avec les composants. Que nous reste-t-il à voir ?
Hé bien... Au niveau des composants on commence à être bons ! Tu as terminé ton apprentissage ! Félicitations ;). Je suis très heureux que tu sois arrivé jusqu'ici ! Si jamais tu as envie d'aller un peu plus loin, d'améliorer certains points, et surtout de savoir vers quoi tu peux te diriger pour continuer ton apprentissage avec Vue.JS, je te laisse me suivre sur le dernier chapitre de ce cours ! J'ai terminé cette partie
Demander de l'assistance